@fruggr/zendesk-mcp-server 2.21.0 → 2.22.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.
Files changed (3) hide show
  1. package/README.md +95 -7
  2. package/dist/index.js +729 -81
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -7,10 +7,19 @@
7
7
  [![Node.js](https://img.shields.io/node/v/@fruggr/zendesk-mcp-server?logo=nodedotjs&logoColor=white&color=339933)](https://nodejs.org)
8
8
 
9
9
  A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that
10
- puts Zendesk inside your AI assistant. It finds answers in the Help Center;
11
- drafts, updates and translates articles while keeping the languages in sync; and
12
- handles Support tickets end to end, comments, triage and image attachments
13
- included. It all happens in plain language, without switching apps.
10
+ puts Zendesk inside your AI assistant, for **both sides of the conversation**.
11
+
12
+ **For agents**: it finds answers in the Help Center; drafts, updates and
13
+ translates articles while keeping the languages in sync; and handles Support
14
+ tickets end to end, comments, triage and image attachments included.
15
+
16
+ **For your customers**: it opens the same door the Help Center's "Submit a
17
+ request" form does. They pick the kind of request, get walked through the
18
+ questions that form actually asks, attach a screenshot, then follow the
19
+ ticket — read the replies, answer back, close it when it's resolved. See
20
+ [End-user mode](#end-user-mode).
21
+
22
+ It all happens in plain language, without switching apps.
14
23
 
15
24
  It does roughly what the
16
25
  [Zendesk agent for Microsoft 365 Copilot](https://support.zendesk.com/hc/en-us/articles/9958331458458-Using-the-Zendesk-agent-in-Microsoft-365-Copilot)
@@ -39,6 +48,9 @@ then calls the right tools on your behalf.
39
48
  - Draft and maintain knowledge-base articles. You can write a new one, or revise
40
49
  a large one a single section at a time, so the whole HTML body never has to
41
50
  round-trip through the model.
51
+ - Submit and follow a request as a customer, not an agent. Choose between the
52
+ kinds of request the vendor offers, answer only the questions that form asks,
53
+ and then track it: what was replied, what you replied, and whether it's done.
42
54
 
43
55
  ## Why this server
44
56
 
@@ -51,6 +63,10 @@ differently.
51
63
  touches exactly what that person is allowed to, the same scoping you get by
52
64
  signing into Zendesk directly. Static API tokens are deliberately not
53
65
  supported ([why](#what-this-server-does-not-do)).
66
+ - Two audiences, one server. Because auth is per-user rather than a shared admin
67
+ key, the same install serves your agents and your customers — the end-user
68
+ surface is a namespace you switch on, speaking the API path Zendesk reserves
69
+ for requesters. Most Zendesk MCP servers are agent-only by construction.
54
70
  - Section-based article editing. For large Help Center articles, read and
55
71
  rewrite one section at a time (parsed by `h1`/`h2`/`h3` headings) instead of
56
72
  shuffling the full HTML body through the assistant. On a targeted edit that
@@ -201,9 +217,10 @@ job: **[docs/http-deployment.md](docs/http-deployment.md)**.
201
217
 
202
218
  ## Tool surface
203
219
 
204
- Tools are grouped into four namespaces: **Tickets**, **Help Center**, **Users &
205
- Organizations** and **Search**. The server registers them in one of three modes,
206
- so you can trade granularity against context budget:
220
+ Tools are grouped into namespaces: **Tickets**, **Help Center**, **Users &
221
+ Organizations**, **Search**, and **Requests** — the end-user surface, which is
222
+ opt-in and covered under [End-user mode](#end-user-mode). The server registers
223
+ them in one of three modes, so you can trade granularity against context budget:
207
224
 
208
225
  - **`all`**: every operation as its own tool, for clients with good tool selection;
209
226
  - **`namespace`** (default): one proxy tool per namespace, a balanced middle ground;
@@ -214,10 +231,81 @@ Proxies take `{ "operation": "<tool_name>", "params": { … } }` and validate
214
231
  filter tools *before* the proxies are built, so each proxy describes only the
215
232
  operations that survive.
216
233
 
234
+ There's one way to pick the inventory (`--namespace` / `--tool`), `--mode`
235
+ packages it, and `--read-only` narrows it. When the combination isn't obvious,
236
+ don't guess — `--print-tools` has the server answer what it would expose, with
237
+ no credentials needed.
238
+
217
239
  Every tool with its description and its `read`/`write` mode:
218
240
  **[docs/mcp-tools-reference.md](docs/mcp-tools-reference.md)**. The flags and
219
241
  worked examples: **[docs/configuration.md](docs/configuration.md)**.
220
242
 
243
+ ## End-user mode
244
+
245
+ The audience for everything above is a Zendesk **agent**. This section is about
246
+ the other one: your **customers**.
247
+
248
+ On the web, a customer opens a ticket through the Help Center's "Submit a
249
+ request" form — they pick a kind of request, fill in the fields that kind asks
250
+ for, attach a file, and later come back to read the replies. End-user mode is
251
+ that same journey, in their assistant.
252
+
253
+ ### Who it's for
254
+
255
+ A vendor pointing their customers at an MCP server, so support happens where
256
+ those customers already work. They install it themselves, sign in with their
257
+ own Help Center account, and never see anything that isn't theirs.
258
+
259
+ ### Turning it on
260
+
261
+ The end-user tools live in the `requests` namespace, and it is **opt-in**: an
262
+ agent install shouldn't inherit tools built for someone else, and one of them
263
+ (marking a request solved) doesn't work under an agent token at all — Zendesk
264
+ accepts it and silently does nothing. `help_center` is worth serving alongside
265
+ it, since a customer who can search the knowledge base often doesn't need to
266
+ open a ticket in the first place.
267
+
268
+ The flags, and how to have the server print what a combination exposes:
269
+ **[docs/configuration.md](docs/configuration.md)**.
270
+
271
+ ### The journey it supports
272
+
273
+ - **See what kinds of request are available.** The forms the vendor offers, by
274
+ their customer-facing names.
275
+ - **Learn what one of them asks.** The questions on that form, which are
276
+ required, and for dropdowns the exact choices — so the assistant can gather
277
+ them in conversation instead of guessing at a payload.
278
+ - **Submit it**, with attachments.
279
+ - **Follow it.** List their requests, read one with its whole conversation
280
+ (each reply attributed, and support agents marked as such), reply back, and
281
+ mark it solved when it is.
282
+
283
+ ### What it asks of the Zendesk account
284
+
285
+ Nothing the agent side doesn't already need, plus one thing: at least one
286
+ ticket form marked visible to end users, because that is what a customer picks
287
+ between. Sign-in is interactive, through a browser, by design — there is no
288
+ scripted or headless path to an end-user token, which is the same protection
289
+ that stops anyone else signing in as your customer.
290
+
291
+ The prerequisites in full: **[docs/configuration.md](docs/configuration.md)**.
292
+ The step-by-step walkthrough, written for someone who doesn't work in a
293
+ terminal: **[docs/end-user-onboarding.md](docs/end-user-onboarding.md)**.
294
+
295
+ ### What a customer can't do — and shouldn't
296
+
297
+ A customer can do less than an agent. That's the point, not a gap:
298
+
299
+ - **Only their own tickets.** Enforced by Zendesk, not by us.
300
+ - **No internal notes**, in either direction. Agent-only notes never appear in
301
+ what this surface returns — Zendesk filters them out of the requester's view
302
+ of a ticket entirely.
303
+ - **No priority or type.** Zendesk drops both when a customer sets them;
304
+ triage stays with the agents.
305
+ - **Closing a ticket only once an agent has picked it up.** Until then Zendesk
306
+ won't let the requester solve it, so the tool says so rather than pretending.
307
+ - **No search across the ticket base**, no user lookups, no views, no macros.
308
+
221
309
  ## Help Center context
222
310
 
223
311
  Beyond tools, the server hands the LLM the structure of *your* Help Center: the
package/dist/index.js CHANGED
@@ -135,6 +135,7 @@ const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("ZENDESK_MAX_RESPONSE_BYTES",
135
135
  const MAX_BASE64_INPUT_CHARS = MESSAGE_CONTENT_BUDGET_BYTES;
136
136
  const MAX_BASE64_INPUT_MB = Number.parseFloat((MAX_BASE64_INPUT_CHARS / 4 * 3 / 1048576).toFixed(2));
137
137
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
138
+ const TICKET_FIELD_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", 10);
138
139
  const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
139
140
  const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
140
141
  const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
@@ -696,8 +697,24 @@ const LogLevel = z.enum([
696
697
  const Namespace = z.enum([
697
698
  "tickets",
698
699
  "help_center",
699
- "users"
700
+ "users",
701
+ "requests"
700
702
  ]);
703
+ /**
704
+ * Namespaces exposed when the operator passes no `--namespace` flag.
705
+ *
706
+ * Deliberately NOT every member of the enum: the end-user `requests` surface
707
+ * is opt-in, because under an agent token part of it silently misbehaves
708
+ * rather than failing -- `solved: true` returns 200 and changes nothing, and
709
+ * `required_in_portal` validation is not applied to agents. Registering it for
710
+ * every agent install would ship an operation that reports success on a no-op.
711
+ * Secondarily, an agent's tool list and context budget stay unchanged.
712
+ */
713
+ const DEFAULT_NAMESPACES = [
714
+ "tickets",
715
+ "help_center",
716
+ "users"
717
+ ];
701
718
  const Transport = z.enum(["stdio", "http"]);
702
719
  const ConfigSchema = z.object({
703
720
  subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
@@ -705,7 +722,18 @@ const ConfigSchema = z.object({
705
722
  logLevel: LogLevel,
706
723
  mode: ToolMode,
707
724
  readOnly: z.boolean(),
708
- namespaces: z.array(Namespace).optional(),
725
+ /**
726
+ * Active namespaces, defaulting to DEFAULT_NAMESPACES rather than everything.
727
+ *
728
+ * The default lives HERE, not in `loadConfig`: the integration harness builds
729
+ * its Config through `ConfigSchema.parse` and never calls `loadConfig`, so a
730
+ * default applied there would leave `requests` visible to every scenario and
731
+ * absent in production.
732
+ *
733
+ * `.min(1)` rejects an explicit `[]`, which `filterTools` reads as no filter
734
+ * at all (it guards on `?.length`) and would expose the opt-in namespace.
735
+ */
736
+ namespaces: z.array(Namespace).min(1).default([...DEFAULT_NAMESPACES]),
709
737
  tools: z.array(z.string()).optional(),
710
738
  /**
711
739
  * Whether to expose the Help Center structural context (the `instructions`
@@ -756,6 +784,14 @@ const ConfigSchema = z.object({
756
784
  * the "Dev mode" section of docs/configuration.md.
757
785
  */
758
786
  dev: z.boolean().default(false),
787
+ /**
788
+ * Print the tool surface the current flags resolve to, then exit without
789
+ * starting a server or touching the network. `--print-tools` exists because
790
+ * the surface is shaped by three independent knobs (`--namespace` / `--tool`
791
+ * pick the inventory, `--mode` packages it, `--read-only` narrows it) and
792
+ * their combination is easier to read off a listing than to predict.
793
+ */
794
+ printTools: z.boolean().default(false),
759
795
  transport: Transport,
760
796
  host: z.string().min(1),
761
797
  port: z.number().int().min(0).max(65535),
@@ -805,7 +841,8 @@ const CLI_OPTIONS = {
805
841
  "read-only": { type: "boolean" },
806
842
  "no-topology": { type: "boolean" },
807
843
  "no-promoted-articles": { type: "boolean" },
808
- dev: { type: "boolean" }
844
+ dev: { type: "boolean" },
845
+ "print-tools": { type: "boolean" }
809
846
  };
810
847
  new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
811
848
  const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
@@ -823,7 +860,8 @@ const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
823
860
  ["read-only", { readOnly: true }],
824
861
  ["no-topology", { topology: false }],
825
862
  ["no-promoted-articles", { promotedArticles: false }],
826
- ["dev", { dev: true }]
863
+ ["dev", { dev: true }],
864
+ ["print-tools", { printTools: true }]
827
865
  ]);
828
866
  const parseCliArgs = (args) => {
829
867
  const { values, positionals } = parseArgs({
@@ -862,6 +900,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
862
900
  const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
863
901
  const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
864
902
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
903
+ const namespaces = cli.namespaces ?? (cli.tools?.length ? [...Namespace.options] : void 0);
865
904
  const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
866
905
  const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
867
906
  return ConfigSchema.parse({
@@ -870,12 +909,13 @@ const loadConfig = (argv = process.argv.slice(2)) => {
870
909
  logLevel: cli.logLevel ?? requireNonEmptyEnv("LOG_LEVEL") ?? "info",
871
910
  mode,
872
911
  readOnly: cli.readOnly ?? false,
873
- namespaces: cli.namespaces,
912
+ namespaces,
874
913
  tools: cli.tools,
875
914
  topology: cli.topology ?? true,
876
915
  promotedArticles: cli.promotedArticles ?? true,
877
916
  hcResourceScheme,
878
917
  dev: cli.dev ?? false,
918
+ printTools: cli.printTools ?? false,
879
919
  callbackPort,
880
920
  ...resolveTransportSettings(cli)
881
921
  });
@@ -1387,6 +1427,25 @@ const formatComment = (comment, authors) => {
1387
1427
  lines.push("", comment.body);
1388
1428
  return lines.join("\n");
1389
1429
  };
1430
+ const formatRequest = (request) => [
1431
+ `## Request #${request.id}: ${request.subject}`,
1432
+ `- **Status**: ${request.status}${request.type ? ` | **Type**: ${request.type}` : ""}${request.priority ? ` | **Priority**: ${request.priority}` : ""}`,
1433
+ request.ticket_form_id ? `- **Form**: ${request.ticket_form_id}` : "",
1434
+ `- **Can you mark it solved**: ${request.can_be_solved_by_me ? "yes" : "no"}`,
1435
+ request.via?.channel ? `- **Submitted via**: ${request.via.channel}` : "",
1436
+ `- **Created**: ${request.created_at} | **Updated**: ${request.updated_at}`,
1437
+ request.description ? `\n${request.description}` : ""
1438
+ ].filter(Boolean).join("\n");
1439
+ const formatRequestComment = (comment, authors) => {
1440
+ const author = authors.get(comment.author_id);
1441
+ const lines = [`### Comment by ${author ? `${author.name}${author.agent ? " (support agent)" : ""}` : `user ${comment.author_id}`}`, `*${comment.created_at}*`];
1442
+ if (comment.attachments?.length) {
1443
+ const summary = comment.attachments.map((a) => `${a.file_name} (#${a.id}, ${a.content_type}) — ${a.content_url}`).join(", ");
1444
+ lines.push(`Attachments: ${summary}`);
1445
+ }
1446
+ lines.push("", comment.body);
1447
+ return lines.join("\n");
1448
+ };
1390
1449
  const formatTagDiff = (before, after) => {
1391
1450
  const b = new Set(Array.isArray(before) ? before.map(String) : []);
1392
1451
  const a = new Set(Array.isArray(after) ? after.map(String) : []);
@@ -1621,15 +1680,13 @@ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
1621
1680
  return truncateIfNeeded(text, "This resource takes no parameters; read a long article one part at a time with get_article_outline then get_article_section.");
1622
1681
  };
1623
1682
  /**
1624
- * Build an article-resources provider. `listPromoted` holds a memoized-promise
1625
- * cache (TTL `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list`
1626
- * calls a client makes; `readArticle` is a one-shot fetch (not cached). As with
1683
+ * Build an article-resources provider. `listPromoted` memoizes its promise (TTL
1684
+ * `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list` calls a
1685
+ * client makes; `readArticle` is a one-shot fetch. As with
1627
1686
  * `createTopologyProvider`, the cache is PER SESSION and must NOT be hoisted to
1628
- * module scope — in HTTP mode this provider is instantiated per session, so a
1629
- * shared cache would leak one caller's data to another. `getToken` is resolved
1630
- * lazily at call time (never at construction) so connecting never triggers the
1631
- * OAuth/PKCE flow. A 401 notifies `onUnauthorized` (stdio OAuth) to drop the
1632
- * stale token, mirroring the topology provider and the tool dispatch path.
1687
+ * module scope — in HTTP mode a shared one would leak a caller's data to
1688
+ * another. `getToken` resolves lazily, so connecting never triggers the OAuth
1689
+ * flow, and a 401 notifies `onUnauthorized` to drop the stale token.
1633
1690
  */
1634
1691
  const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized) => {
1635
1692
  let cached;
@@ -1898,10 +1955,19 @@ const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
1898
1955
  };
1899
1956
  //#endregion
1900
1957
  //#region src/routing/registry.ts
1958
+ /**
1959
+ * The single authority on which tools a config exposes.
1960
+ *
1961
+ * Every filter lives here rather than at the call sites, because there is more
1962
+ * than one consumer -- `registerToolset` registers them and `renderToolSurface`
1963
+ * prints them -- and a filter applied in only one of the two makes
1964
+ * `--print-tools` lie about the running server.
1965
+ */
1901
1966
  const filterTools = (allTools, options) => allTools.filter((tool) => {
1902
1967
  if (options.readOnly && !tool.readOnly) return false;
1903
1968
  if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
1904
1969
  if (options.tools?.length && !options.tools.includes(tool.name)) return false;
1970
+ if (options.promotedArticles === false && tool.name === "list_promoted_articles") return false;
1905
1971
  return true;
1906
1972
  });
1907
1973
  const groupByNamespace = (tools) => {
@@ -1913,6 +1979,34 @@ const groupByNamespace = (tools) => {
1913
1979
  }
1914
1980
  return grouped;
1915
1981
  };
1982
+ /**
1983
+ * Proxy tool name and title per namespace, used by `namespace` mode.
1984
+ *
1985
+ * Typed `Record<Namespace, …>` rather than `Record<string, …>` so the compiler
1986
+ * rejects this literal outright when a namespace is added to the enum without
1987
+ * a label here. That matters because the consumer looks the label up and skips
1988
+ * the namespace when it is missing: an incomplete map would silently expose no
1989
+ * proxy for a whole namespace, with every existing test still green. Making it
1990
+ * a type error is stronger than any test could be.
1991
+ */
1992
+ const NAMESPACE_LABELS = {
1993
+ tickets: {
1994
+ toolName: "zendesk_tickets",
1995
+ title: "Zendesk Tickets"
1996
+ },
1997
+ help_center: {
1998
+ toolName: "zendesk_help_center",
1999
+ title: "Zendesk Help Center"
2000
+ },
2001
+ users: {
2002
+ toolName: "zendesk_users",
2003
+ title: "Zendesk Users"
2004
+ },
2005
+ requests: {
2006
+ toolName: "zendesk_requests",
2007
+ title: "Zendesk Requests"
2008
+ }
2009
+ };
1916
2010
  //#endregion
1917
2011
  //#region src/utils/article-order.ts
1918
2012
  const hasPositionInversion = (order) => {
@@ -3181,6 +3275,526 @@ const createHelpCenterTools = (ctx) => {
3181
3275
  ];
3182
3276
  };
3183
3277
  //#endregion
3278
+ //#region src/tools/attachments.ts
3279
+ /**
3280
+ * File-attachment input, shared by every tool that can carry files on a comment.
3281
+ *
3282
+ * Lives here rather than inside a tool factory because both audiences need it:
3283
+ * an agent attaching a screenshot to a public reply, and an end user attaching a
3284
+ * log to their own request. The Uploads API (`POST /api/v2/uploads.json`) is
3285
+ * documented as allowed for end users, so the same code path serves both.
3286
+ */
3287
+ const attachmentSchema = z.object({
3288
+ file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
3289
+ file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
3290
+ abort: true,
3291
+ error: (issue) => `Attachment too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split the upload, or link to it instead of uploading.`
3292
+ }).base64().describe(`File content encoded as base64. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file), and the attachments of one call must stay under that total; the HTTP transport additionally caps request bodies at 4 MB.`),
3293
+ content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
3294
+ });
3295
+ /**
3296
+ * The optional `attachments` array parameter, capped as a whole.
3297
+ *
3298
+ * The per-file cap in `attachmentSchema` bounds one attachment; this bounds a
3299
+ * call, because `attachments` is a list and every file rides in the same
3300
+ * message (#205).
3301
+ */
3302
+ const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
3303
+ const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
3304
+ if (total > 10420224) refinement.addIssue({
3305
+ code: "custom",
3306
+ message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
3307
+ });
3308
+ }).optional().describe(description);
3309
+ /**
3310
+ * Upload each file via the Zendesk Uploads API, aggregating them under a single
3311
+ * upload token (the token from the first upload is passed to the next), and
3312
+ * return that token for use in a comment's `uploads` array. Sequential of
3313
+ * necessity: each call needs the previous token.
3314
+ */
3315
+ const uploadAttachments = async (subdomain, token, files) => {
3316
+ let uploadToken;
3317
+ for (const file of files) {
3318
+ const { upload } = await zendeskUpload(subdomain, token, file.file_name, Buffer.from(file.file_base64, "base64"), file.content_type, uploadToken);
3319
+ uploadToken = upload.token;
3320
+ }
3321
+ return uploadToken;
3322
+ };
3323
+ const formatAttachmentSuffix = (count) => count ? ` with ${count} attachment(s)` : "";
3324
+ //#endregion
3325
+ //#region src/tools/requests.ts
3326
+ const forbidden = (tool, endpoint, error, hint) => new Error(`${tool} reads the end-user Requests surface (${endpoint}), which Zendesk serves to a signed-in requester. The current token was refused (HTTP 403). Either the Help Center is closed to this account, or this token is not a Help-Center-enabled user.${hint ? ` ${hint}` : ""} For the agent-side equivalents use create_ticket, get_ticket, list_tickets or add_public_comment (namespace: tickets).`, { cause: error });
3327
+ const OTHER_USERS_REQUEST_HINT = "A request id that belongs to another user is refused the same way, so check the id is one of your own.";
3328
+ /** Run `fetch`, rewriting a 403 into guidance naming the end-user surface. */
3329
+ const withForbiddenGuidance = async (tool, endpoint, fetch, hint) => {
3330
+ try {
3331
+ return await fetch();
3332
+ } catch (error) {
3333
+ if (error instanceof ZendeskApiError && error.status === 403) throw forbidden(tool, endpoint, error, hint);
3334
+ throw error;
3335
+ }
3336
+ };
3337
+ const END_USER_FORM_PARAMS = {
3338
+ active: "true",
3339
+ end_user_visible: "true",
3340
+ fallback_to_default: "true"
3341
+ };
3342
+ /**
3343
+ * Walk an offset-paginated listing to the end, following `next_page` and
3344
+ * nothing else.
3345
+ *
3346
+ * `next_page` is the only trustworthy end-of-list signal on these endpoints:
3347
+ * `count` can be the pre-filter total, and a short page can still be followed
3348
+ * by another. Hitting the cap throws rather than returning a partial list --
3349
+ * for both callers a missing entry is worse than an error, because it makes a
3350
+ * form invisible or a required field unenforced, and the caller then gets a
3351
+ * confidently wrong "no such form" or an opaque Zendesk 422.
3352
+ */
3353
+ const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {}, maxPages = TICKET_FIELD_SCAN_MAX_PAGES, capEnvVar = "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", onPage, forbiddenHint }) => {
3354
+ const items = [];
3355
+ let page = 1;
3356
+ while (true) {
3357
+ const response = await withForbiddenGuidance(tool, `GET ${path}`, () => zendeskGet(subdomain, token, path, {
3358
+ ...params,
3359
+ ...buildOffsetParams(100, page)
3360
+ }), forbiddenHint);
3361
+ items.push(...extract(response) ?? []);
3362
+ onPage?.(response);
3363
+ if (!response.next_page) return items;
3364
+ if (page >= maxPages) throw new Error(`${tool} could not read all of ${path}: the account exposes more than ${maxPages} pages of it. Continuing from a partial list would hide entries and produce a confidently wrong answer, so this stops here. Raise ${capEnvVar} and retry.`);
3365
+ page += 1;
3366
+ }
3367
+ };
3368
+ const fetchEndUserForms = async (subdomain, token, tool) => fetchAllPages({
3369
+ subdomain,
3370
+ token,
3371
+ tool,
3372
+ path: "/ticket_forms",
3373
+ extract: (response) => response.ticket_forms,
3374
+ params: END_USER_FORM_PARAMS,
3375
+ forbiddenHint: "This endpoint can also be unavailable on a Zendesk plan without multiple ticket forms."
3376
+ });
3377
+ /**
3378
+ * Every ticket field the caller can see, paged defensively.
3379
+ *
3380
+ * `GET /ticket_fields` cannot be trusted to say when it is done. Under an
3381
+ * end-user token its `count` is the UNFILTERED total (27 while returning 8
3382
+ * objects with `next_page: null`), and in cursor mode `page[size]` is applied
3383
+ * before the visibility filter, so a short page is not the last page. So this
3384
+ * follows `next_page` and nothing else, bounded by a page cap.
3385
+ *
3386
+ * The per-field lookup would be the obvious alternative and is not available:
3387
+ * `GET /ticket_fields/{id}` answers 403 for an end user. The list is the only
3388
+ * way in.
3389
+ */
3390
+ const fetchVisibleTicketFields = async (subdomain, token, tool) => {
3391
+ return (await fetchAllPages({
3392
+ subdomain,
3393
+ token,
3394
+ tool,
3395
+ path: "/ticket_fields",
3396
+ extract: (response) => response.ticket_fields
3397
+ })).filter((field) => field.visible_in_portal !== false);
3398
+ };
3399
+ const fetchAllRequestComments = async (subdomain, token, requestId) => {
3400
+ const authors = /* @__PURE__ */ new Map();
3401
+ return {
3402
+ comments: await fetchAllPages({
3403
+ subdomain,
3404
+ token,
3405
+ tool: "get_request",
3406
+ path: `/requests/${requestId}/comments`,
3407
+ extract: (response) => response.comments,
3408
+ params: { include: "users" },
3409
+ maxPages: MAX_COMMENT_PAGES,
3410
+ capEnvVar: "ZENDESK_MAX_COMMENT_PAGES",
3411
+ forbiddenHint: OTHER_USERS_REQUEST_HINT,
3412
+ onPage: (response) => {
3413
+ for (const user of response.users ?? []) authors.set(user.id, user);
3414
+ }
3415
+ }),
3416
+ authors
3417
+ };
3418
+ };
3419
+ const SYSTEM_FIELD_TYPES = /* @__PURE__ */ new Set([
3420
+ "subject",
3421
+ "description",
3422
+ "status",
3423
+ "tickettype",
3424
+ "priority",
3425
+ "group",
3426
+ "assignee",
3427
+ "tags",
3428
+ "custom_status"
3429
+ ]);
3430
+ const isCustomField = (field) => !SYSTEM_FIELD_TYPES.has(field.type);
3431
+ const formSummary = (form) => [`- **${form.display_name || form.name}** (form id ${form.id})${form.default ? " — default" : ""}`, form.display_name && form.display_name !== form.name ? ` - Internal name: ${form.name}` : ""].filter(Boolean).join("\n");
3432
+ const fieldSpec = (field) => {
3433
+ const options = field.custom_field_options ?? field.system_field_options ?? [];
3434
+ return [
3435
+ `### ${field.title_in_portal || field.title} (field id ${field.id})`,
3436
+ `- **Type**: ${field.type} | **${field.required_in_portal ? "required" : "optional"}**`,
3437
+ field.description ? `- **Help text**: ${field.description}` : "",
3438
+ options.length > 0 ? "- **Accepted values** (label → value to send):" : "",
3439
+ ...options.map((option) => ` - ${option.name} → ${option.value}`)
3440
+ ].filter(Boolean).join("\n");
3441
+ };
3442
+ const fieldRef = (id, byId) => {
3443
+ const field = byId.get(id);
3444
+ return field ? `${field.title_in_portal || field.title} (field id ${id})` : `field ${id}`;
3445
+ };
3446
+ /**
3447
+ * Whether a conditional child field is required *of a submitter*, in words.
3448
+ *
3449
+ * `is_required` alone is not the answer: `required_on_statuses` narrows it to
3450
+ * particular statuses, and saying "then required" for a field required only
3451
+ * "when open" sends the assistant hunting for an answer Zendesk will not ask
3452
+ * for. A new request starts at `new`, so that is the status read for -- but a
3453
+ * trigger can move it off `new` at once, hence wording that says when the field
3454
+ * becomes required rather than that it never will.
3455
+ */
3456
+ const childRequirement = (child) => {
3457
+ if (!child.is_required) return "";
3458
+ const scope = child.required_on_statuses;
3459
+ if (!scope || scope.type === "ALL_STATUSES") return " (then required)";
3460
+ if (scope.type === "NO_STATUSES") return "";
3461
+ const statuses = scope.statuses ?? [];
3462
+ if (statuses.length === 0) return " (then required)";
3463
+ return statuses.includes("new") ? " (then required)" : ` (then required once the request is ${statuses.join(" or ")}, not to submit it)`;
3464
+ };
3465
+ const conditionSpec = (condition, byId) => {
3466
+ const children = (condition.child_fields ?? []).map((child) => `${fieldRef(child.id, byId)}${childRequirement(child)}`).join(", ");
3467
+ return `- When ${fieldRef(condition.parent_field_id, byId)} is \`${String(condition.value)}\`: ${children || "no additional fields"}`;
3468
+ };
3469
+ const renderFormSpec = (form, fields) => {
3470
+ const byId = new Map(fields.map((field) => [field.id, field]));
3471
+ const formFields = form.ticket_field_ids.map((id) => byId.get(id)).filter((field) => field !== void 0).filter(isCustomField);
3472
+ const required = formFields.filter((field) => field.required_in_portal);
3473
+ const conditions = form.end_user_conditions ?? [];
3474
+ return [
3475
+ `# ${form.display_name || form.name} (form id ${form.id})`,
3476
+ "",
3477
+ "Always needed: a subject and a description (the `subject` and `body` parameters",
3478
+ "of create_request). The fields below are what this form asks for on top of those.",
3479
+ "",
3480
+ required.length > 0 ? `**Required**: ${required.map((f) => f.title_in_portal || f.title).join(", ")}` : "**Required**: nothing beyond the subject and description.",
3481
+ conditions.length > 0 ? [
3482
+ "",
3483
+ "## Conditional fields",
3484
+ "Some fields appear, or become required, only for certain answers. Ask for them once",
3485
+ "the controlling answer is known rather than up front:",
3486
+ ...conditions.map((condition) => conditionSpec(condition, byId))
3487
+ ].join("\n") : "",
3488
+ "",
3489
+ "## Fields",
3490
+ "",
3491
+ formFields.length > 0 ? formFields.map(fieldSpec).join("\n\n") : "This form exposes no custom fields; send subject and body only."
3492
+ ].filter(Boolean).join("\n");
3493
+ };
3494
+ const isEmptyAnswer = (value) => value === null || value === "" || Array.isArray(value) && value.length === 0;
3495
+ const optionValues = (field) => field.custom_field_options?.length ? new Set(field.custom_field_options.map((option) => option.value)) : null;
3496
+ /**
3497
+ * Refuse a submission the API would accept and quietly mangle. Three ways it
3498
+ * does: an unknown `ticket_form_id` gets 201 on the DEFAULT form; a missing
3499
+ * `required_in_portal` field is enforced against end users only, so an agent
3500
+ * token gets 201 with an empty subject; and an option value the form does not
3501
+ * offer is dropped, leaving 201 and an empty field.
3502
+ *
3503
+ * Only UNCONDITIONALLY required fields are enforced -- one required through
3504
+ * `end_user_conditions` depends on answers we may not have, and Zendesk's 422
3505
+ * is the backstop.
3506
+ */
3507
+ const validateSubmission = (form, fields, provided) => {
3508
+ const byId = new Map(fields.map((field) => [field.id, field]));
3509
+ const formFieldIds = new Set(form.ticket_field_ids);
3510
+ const offForm = provided.filter((entry) => {
3511
+ const field = byId.get(entry.id);
3512
+ return !formFieldIds.has(entry.id) || !field || !isCustomField(field);
3513
+ });
3514
+ if (offForm.length > 0) throw new Error(`This form does not take those fields: ${offForm.map((entry) => entry.id).join(", ")}. Zendesk would accept the submission and drop them. Call get_request_form for the ids it does take; the subject and the description are this tool's own parameters, not custom_fields entries.`);
3515
+ const answered = provided.filter((entry) => !isEmptyAnswer(entry.value));
3516
+ const providedIds = new Set(answered.map((e) => e.id));
3517
+ const missing = form.ticket_field_ids.map((id) => byId.get(id)).filter((field) => field?.required_in_portal === true).filter(isCustomField).filter((field) => !providedIds.has(field.id));
3518
+ if (missing.length > 0) {
3519
+ const list = missing.map((field) => `${field.title_in_portal || field.title} (field id ${field.id})`).join(", ");
3520
+ throw new Error(`This form requires values the submission does not carry: ${list}. Ask for them, then resend with those ids in custom_fields. Call get_request_form for their accepted values.`);
3521
+ }
3522
+ const rejected = answered.flatMap((entry) => {
3523
+ const field = byId.get(entry.id);
3524
+ const accepted = field && optionValues(field);
3525
+ if (!field || !accepted) return [];
3526
+ const unknown = (Array.isArray(entry.value) ? entry.value : [entry.value]).filter((value) => typeof value !== "string" || !accepted.has(value));
3527
+ return unknown.length > 0 ? [`${field.title_in_portal || field.title} (field id ${field.id}) got ${unknown.map((value) => JSON.stringify(value)).join(", ")}, accepts ${[...accepted].map((value) => JSON.stringify(value)).join(", ")}`] : [];
3528
+ });
3529
+ if (rejected.length > 0) throw new Error(`This form does not offer those answers: ${rejected.join("; ")}. Zendesk would accept the submission and drop them, leaving the request without the answers it asked for, so this is refused instead. Call get_request_form for the exact values to send.`);
3530
+ };
3531
+ const REQUEST_STATUS = z.enum([
3532
+ "new",
3533
+ "open",
3534
+ "pending",
3535
+ "hold",
3536
+ "solved",
3537
+ "closed"
3538
+ ]);
3539
+ const createRequestTools = (ctx) => {
3540
+ const { subdomain, getToken } = ctx;
3541
+ const resolveForm = async (tool, token, formId) => {
3542
+ const [forms, fields] = await Promise.all([fetchEndUserForms(subdomain, token, tool), fetchVisibleTicketFields(subdomain, token, tool)]);
3543
+ if (forms.length === 0) throw new Error(`No request form is available to this user on ${subdomain}.zendesk.com. The Help Center may be closed, or no form is marked visible to end users. An agent can still open a ticket with create_ticket (namespace: tickets).`);
3544
+ const form = formId === void 0 ? forms.find((f) => f.default) ?? (forms.length === 1 ? forms[0] : void 0) : forms.find((f) => f.id === formId);
3545
+ if (!form) {
3546
+ const available = forms.map((f) => `${f.display_name || f.name} (${f.id})`).join(", ");
3547
+ throw new Error(formId === void 0 ? `No default request form on ${subdomain}.zendesk.com. Pick one explicitly: ${available}.` : `No request form with id ${formId} is available to you. Available: ${available}. Sending an unknown id would make Zendesk silently substitute the default form, so this is refused rather than guessed.`);
3548
+ }
3549
+ return {
3550
+ form,
3551
+ fields
3552
+ };
3553
+ };
3554
+ return [
3555
+ {
3556
+ name: "list_request_forms",
3557
+ namespace: "requests",
3558
+ readOnly: true,
3559
+ title: "List Request Forms",
3560
+ description: "List the kinds of request you can submit to this Zendesk — the forms a customer picks between on the Help Center, such as \"Report a bug\" or \"Feature request\". Returns each form's customer-facing name, its numeric id, and which one is the account default. Call this first when opening a request so the user chooses the right kind, then get_request_form to learn what that form asks for. Only forms that are active and visible to end users are listed; an account with a single form returns just that one. This is the end-user view: agents picking a form for someone else want list_ticket_fields and create_ticket instead.",
3561
+ inputSchema: z.object({}),
3562
+ annotations: {
3563
+ readOnlyHint: true,
3564
+ destructiveHint: false,
3565
+ idempotentHint: true,
3566
+ openWorldHint: true
3567
+ },
3568
+ handler: async () => {
3569
+ const token = await getToken();
3570
+ const forms = await fetchEndUserForms(subdomain, token, "list_request_forms");
3571
+ if (forms.length === 0) return { content: [{
3572
+ type: "text",
3573
+ text: "No request form is available to you on this Zendesk. The Help Center may be closed, or no form is marked visible to end users."
3574
+ }] };
3575
+ const text = [
3576
+ `${forms.length} kind(s) of request available:`,
3577
+ "",
3578
+ forms.map(formSummary).join("\n"),
3579
+ "",
3580
+ "Call get_request_form with a form id to see the questions it asks."
3581
+ ].join("\n");
3582
+ return { content: [{
3583
+ type: "text",
3584
+ text: truncateIfNeeded(text, "list_request_forms takes no parameters, so this listing cannot be narrowed from the call; read one form in full with get_request_form.")
3585
+ }] };
3586
+ }
3587
+ },
3588
+ {
3589
+ name: "get_request_form",
3590
+ namespace: "requests",
3591
+ readOnly: true,
3592
+ title: "Get Request Form",
3593
+ description: "Read what one request form actually asks for, so the user can be walked through it question by question. Returns the fields on that form in the order the portal shows them, each with its customer-facing label, whether it is required, and for dropdowns the exact values Zendesk accepts — plus any conditional rules (\"if Type is Bug, Version becomes required\"). Joins the form definition with the field definitions, because the form itself carries only field ids. Use it after list_request_forms and before create_request; omit form_id to describe the account default form. Conditional fields are reported as rules rather than resolved, since which ones apply depends on answers not yet given.",
3594
+ inputSchema: z.object({ form_id: z.number().int().optional().describe("Form id whose questions to read, as returned by list_request_forms. Omit to describe the account default form.") }),
3595
+ annotations: {
3596
+ readOnlyHint: true,
3597
+ destructiveHint: false,
3598
+ idempotentHint: true,
3599
+ openWorldHint: true
3600
+ },
3601
+ handler: async (params) => {
3602
+ const { form_id } = params;
3603
+ const token = await getToken();
3604
+ const { form, fields } = await resolveForm("get_request_form", token, form_id);
3605
+ return { content: [{
3606
+ type: "text",
3607
+ text: truncateIfNeeded(renderFormSpec(form, fields), "get_request_form takes only form_id, so this description cannot be narrowed from the call; the fields it lists are the ones this form asks for.")
3608
+ }] };
3609
+ }
3610
+ },
3611
+ {
3612
+ name: "create_request",
3613
+ namespace: "requests",
3614
+ readOnly: false,
3615
+ title: "Submit a Request",
3616
+ description: "Submit a new support request as the signed-in user — the MCP equivalent of the Help Center's \"Submit a request\" form. Returns the created request with its number, which the user can then follow with list_requests and get_request. The submission is checked before sending: an unknown form id, a missing field the form marks required, or a dropdown answer the form does not offer is refused here rather than sent, because Zendesk would answer 201 having silently substituted the default form, accepted an empty subject or dropped the unrecognised value. Call get_request_form first to learn which fields to gather. Priority and type cannot be set by an end user — Zendesk drops them — so triage is left to the agents. This posts as the authenticated user; an agent opening a ticket on someone else's behalf wants create_ticket instead.",
3617
+ inputSchema: z.object({
3618
+ subject: z.string().min(1).describe("One-line summary of the request, shown as the ticket title to the support agents who pick it up."),
3619
+ body: z.string().min(1).describe("The request itself: what happened, what was expected, and any steps to reproduce. Becomes the first public comment on the ticket."),
3620
+ form_id: z.number().int().optional().describe("Form id chosen from list_request_forms, deciding which questions apply. Omit to use the account default form."),
3621
+ custom_fields: z.array(z.object({
3622
+ id: z.number().int(),
3623
+ value: z.unknown()
3624
+ })).optional().describe("Answers to the form's own questions, as { id, value } pairs. Take both the ids and the accepted values from get_request_form; an id this form does not carry, or a value a dropdown does not offer, is refused here because Zendesk would drop it without an error. Option values are the tag strings, never numbers."),
3625
+ attachments: attachmentsParam("Files to attach to the request, such as a screenshot or a log, with their content base64-encoded.")
3626
+ }),
3627
+ annotations: {
3628
+ readOnlyHint: false,
3629
+ destructiveHint: false,
3630
+ idempotentHint: false,
3631
+ openWorldHint: true
3632
+ },
3633
+ handler: async (params) => {
3634
+ const { subject, body, form_id, custom_fields, attachments } = params;
3635
+ const token = await getToken();
3636
+ const { form, fields } = await resolveForm("create_request", token, form_id);
3637
+ validateSubmission(form, fields, custom_fields ?? []);
3638
+ const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
3639
+ const { request } = await withForbiddenGuidance("create_request", "POST /requests", () => zendeskPost(subdomain, token, "/requests", { request: {
3640
+ subject,
3641
+ ticket_form_id: form.id,
3642
+ comment: {
3643
+ body,
3644
+ ...uploads && { uploads }
3645
+ },
3646
+ ...custom_fields && { custom_fields }
3647
+ } }));
3648
+ const suffix = formatAttachmentSuffix(attachments?.length);
3649
+ return { content: [{
3650
+ type: "text",
3651
+ text: `Request #${request.id} submitted${suffix}.\n\n${formatRequest(request)}`
3652
+ }] };
3653
+ }
3654
+ },
3655
+ {
3656
+ name: "list_requests",
3657
+ namespace: "requests",
3658
+ readOnly: true,
3659
+ title: "List My Requests",
3660
+ description: "List the requests the signed-in user submitted, newest activity first by default. Returns each request with its number, subject, status and whether the user can mark it solved; pass a query to search their own requests by free text instead of listing all of them. Scoped to the caller by Zendesk itself — it can never return anyone else's tickets — which is why an agent calling this sees only requests they raised, not their queue. Use get_request to read one with its full conversation. Agents wanting a queue want list_tickets, search_tickets or get_view_tickets instead.",
3661
+ inputSchema: z.object({
3662
+ query: z.string().min(1).optional().describe("Free text to match against the caller's own requests. When given, the search endpoint is used instead of the plain listing; omit it to list everything."),
3663
+ status: z.array(REQUEST_STATUS).min(1).optional().describe("Restrict to these ticket states, e.g. [\"open\",\"pending\"] for anything still being worked. Validated here because Zendesk silently returns every request when it does not recognise a status."),
3664
+ sort_by: z.enum(["created_at", "updated_at"]).default("updated_at").describe("Which timestamp orders the results: updated_at surfaces recent activity, created_at the newest submissions."),
3665
+ sort_order: z.enum(["asc", "desc"]).default("desc").describe("Direction of that ordering; desc puts the most recent first, which is what a user following their tickets wants."),
3666
+ per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
3667
+ page: z.number().int().min(1).default(1).describe(PAGE_DESC)
3668
+ }),
3669
+ annotations: {
3670
+ readOnlyHint: true,
3671
+ destructiveHint: false,
3672
+ idempotentHint: true,
3673
+ openWorldHint: true
3674
+ },
3675
+ handler: async (params) => {
3676
+ const { query, status, sort_by, sort_order, per_page, page } = params;
3677
+ const token = await getToken();
3678
+ const path = query ? "/requests/search" : "/requests";
3679
+ const response = await withForbiddenGuidance("list_requests", `GET ${path}`, () => zendeskGet(subdomain, token, path, {
3680
+ ...query && { query },
3681
+ ...status?.length && { status: status.join(",") },
3682
+ sort_by,
3683
+ sort_order,
3684
+ ...buildOffsetParams(per_page, page)
3685
+ }));
3686
+ const requests = response.requests ?? [];
3687
+ const meta = extractOffsetPaginationMeta(response, requests.length, per_page, page);
3688
+ if (requests.length === 0) return { content: [{
3689
+ type: "text",
3690
+ text: query ? `No request of yours matches "${query}".` : "You have not submitted any request matching this filter."
3691
+ }] };
3692
+ return { content: [{
3693
+ type: "text",
3694
+ text: `${query ? `Searched your requests for "${query}".` : "Your requests."}\n\n${formatList(requests, formatRequest, meta)}`
3695
+ }] };
3696
+ }
3697
+ },
3698
+ {
3699
+ name: "get_request",
3700
+ namespace: "requests",
3701
+ readOnly: true,
3702
+ title: "Get My Request",
3703
+ description: "Read one of the signed-in user's own requests, optionally with the whole conversation on it. Returns the request's status, the form it was submitted on, whether the user may mark it solved, and — with include_comments — every public message on it, each labelled with its author and marked when that author is a support agent. Internal agent notes are never returned on this path, so nothing agent-private can leak through it. A request belonging to someone else answers \"permission denied\" rather than \"not found\", so a denial here does not tell you whether that id exists. Find the number with list_requests; agents reading a ticket they do not own want get_ticket instead.",
3704
+ inputSchema: z.object({
3705
+ request_id: z.number().int().describe("Number of the request to read, as shown by list_requests or returned when it was submitted."),
3706
+ include_comments: z.boolean().default(false).describe("When true, appends the full public conversation — agent replies and the user's own messages. Defaults to false so a status check stays cheap.")
3707
+ }),
3708
+ annotations: {
3709
+ readOnlyHint: true,
3710
+ destructiveHint: false,
3711
+ idempotentHint: true,
3712
+ openWorldHint: true
3713
+ },
3714
+ handler: async (params) => {
3715
+ const { request_id, include_comments } = params;
3716
+ const token = await getToken();
3717
+ const { request } = await withForbiddenGuidance("get_request", `GET /requests/${request_id}`, () => zendeskGet(subdomain, token, `/requests/${request_id}`), OTHER_USERS_REQUEST_HINT);
3718
+ let text = formatRequest(request);
3719
+ if (include_comments) {
3720
+ const { comments, authors } = await fetchAllRequestComments(subdomain, token, request_id);
3721
+ const thread = comments.map((c) => formatRequestComment(c, authors)).join("\n\n");
3722
+ text += `\n\n---\n# Conversation\n\n${thread}`;
3723
+ }
3724
+ const advice = include_comments ? `get_request appends the whole conversation as one unpaginated block; call it again with include_comments false to read request #${request_id}'s status and description alone.` : "get_request takes no pagination or filter parameters, so this response cannot be narrowed from the call.";
3725
+ return { content: [{
3726
+ type: "text",
3727
+ text: truncateIfNeeded(text, advice)
3728
+ }] };
3729
+ }
3730
+ },
3731
+ {
3732
+ name: "add_request_comment",
3733
+ namespace: "requests",
3734
+ readOnly: false,
3735
+ title: "Reply on My Request",
3736
+ description: "Reply on one of the signed-in user's own requests, optionally attaching files. Returns confirmation with the request's status after the reply, which matters because replying on a request that was already solved REOPENS it — Zendesk moves it back to open, and the user should know that is what their message did. The comment is always public: end users cannot post internal notes, and Zendesk silently ignores any attempt to mark one private. A closed request rejects new comments outright. Agents replying to a requester want add_public_comment, or add_private_note for an internal note.",
3737
+ inputSchema: z.object({
3738
+ request_id: z.number().int().describe("Number of the request to reply on, as shown by list_requests."),
3739
+ body: z.string().min(1).describe("The message to send. Visible to the support agents on the ticket and included in their email notification."),
3740
+ attachments: attachmentsParam("Files to attach to this reply, such as a screenshot or a log, with their content base64-encoded.")
3741
+ }),
3742
+ annotations: {
3743
+ readOnlyHint: false,
3744
+ destructiveHint: false,
3745
+ idempotentHint: false,
3746
+ openWorldHint: true
3747
+ },
3748
+ handler: async (params) => {
3749
+ const { request_id, body, attachments } = params;
3750
+ const token = await getToken();
3751
+ const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
3752
+ const { request } = await withForbiddenGuidance("add_request_comment", `PUT /requests/${request_id}`, () => zendeskPut(subdomain, token, `/requests/${request_id}`, { request: { comment: {
3753
+ body,
3754
+ ...uploads && { uploads }
3755
+ } } }), OTHER_USERS_REQUEST_HINT);
3756
+ return { content: [{
3757
+ type: "text",
3758
+ text: `Reply added to request #${request_id}${formatAttachmentSuffix(attachments?.length)}. It is now **${request.status}**.`
3759
+ }] };
3760
+ }
3761
+ },
3762
+ {
3763
+ name: "mark_request_solved",
3764
+ namespace: "requests",
3765
+ readOnly: false,
3766
+ title: "Mark My Request Solved",
3767
+ description: "Close one of the signed-in user's own requests, for when they consider it resolved. Returns the request's status read back from Zendesk, not merely an acknowledgement: this operation is refused unless the request is actually solvable by its requester, because Zendesk answers 200 and changes nothing when it is not, which would otherwise be reported as success. A request is only solvable once an agent has been assigned to it, so a brand-new or unassigned one cannot be closed this way — leave it, or reply with add_request_comment to say it is no longer needed. Marking an already-solved request solved again is a no-op. Agents closing a ticket want update_ticket with status solved.",
3768
+ inputSchema: z.object({ request_id: z.number().int().describe("Number of the request to close. Check get_request first: its \"can you mark it solved\" line has to say yes.") }),
3769
+ annotations: {
3770
+ readOnlyHint: false,
3771
+ destructiveHint: true,
3772
+ idempotentHint: true,
3773
+ openWorldHint: true
3774
+ },
3775
+ handler: async (params) => {
3776
+ const { request_id } = params;
3777
+ const token = await getToken();
3778
+ const endpoint = `/requests/${request_id}`;
3779
+ const { request: before } = await withForbiddenGuidance("mark_request_solved", `GET ${endpoint}`, () => zendeskGet(subdomain, token, endpoint), OTHER_USERS_REQUEST_HINT);
3780
+ if (before.status === "solved" || before.status === "closed") return { content: [{
3781
+ type: "text",
3782
+ text: `Request #${request_id} is already **${before.status}**. Nothing to do.`
3783
+ }] };
3784
+ if (!before.can_be_solved_by_me) return { content: [{
3785
+ type: "text",
3786
+ text: `Request #${request_id} cannot be marked solved by you: Zendesk only allows that once an agent has been assigned to it. It is currently **${before.status}**. Sending it anyway would return success and change nothing, so it was not sent. Use add_request_comment to tell the agents it is resolved on your side.`
3787
+ }] };
3788
+ const { request: after } = await withForbiddenGuidance("mark_request_solved", `PUT ${endpoint}`, () => zendeskPut(subdomain, token, endpoint, { request: { solved: true } }), OTHER_USERS_REQUEST_HINT);
3789
+ return { content: [{
3790
+ type: "text",
3791
+ text: after.status === "solved" ? `Request #${request_id} is now **solved**.` : `Zendesk accepted the update but request #${request_id} is still **${after.status}**, so it was not solved. Two causes produce this silently: the token belongs to an agent rather than to the requester -- Zendesk answers 200 and ignores \`solved\` for them -- or the assignment changed between the check and the update. Re-read it with get_request.`
3792
+ }] };
3793
+ }
3794
+ }
3795
+ ];
3796
+ };
3797
+ //#endregion
3184
3798
  //#region src/tools/search.ts
3185
3799
  const formatSearchResult = (result) => {
3186
3800
  const lines = [`## [${result["result_type"]}] #${result["id"]}`];
@@ -3543,30 +4157,6 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
3543
4157
  };
3544
4158
  const createTicketTools = (ctx) => {
3545
4159
  const { subdomain, getToken } = ctx;
3546
- const attachmentSchema = z.object({
3547
- file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
3548
- file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
3549
- abort: true,
3550
- error: (issue) => `Attachment too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split the upload, or link to it instead of uploading.`
3551
- }).base64().describe(`File content encoded as base64. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file), and the attachments of one call must stay under that total; the HTTP transport additionally caps request bodies at 4 MB.`),
3552
- content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
3553
- });
3554
- const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
3555
- const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
3556
- if (total > 10420224) refinement.addIssue({
3557
- code: "custom",
3558
- message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
3559
- });
3560
- }).optional().describe(description);
3561
- const uploadAttachments = async (token, files) => {
3562
- let uploadToken;
3563
- for (const file of files) {
3564
- const { upload } = await zendeskUpload(subdomain, token, file.file_name, Buffer.from(file.file_base64, "base64"), file.content_type, uploadToken);
3565
- uploadToken = upload.token;
3566
- }
3567
- return uploadToken;
3568
- };
3569
- const formatAttachmentSuffix = (count) => count ? ` with ${count} attachment(s)` : "";
3570
4160
  return [
3571
4161
  {
3572
4162
  name: "get_ticket",
@@ -3881,7 +4471,7 @@ const createTicketTools = (ctx) => {
3881
4471
  handler: async (params) => {
3882
4472
  const { ticket_id, body, attachments } = params;
3883
4473
  const token = await getToken();
3884
- const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
4474
+ const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
3885
4475
  await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
3886
4476
  body,
3887
4477
  public: false,
@@ -3913,7 +4503,7 @@ const createTicketTools = (ctx) => {
3913
4503
  handler: async (params) => {
3914
4504
  const { ticket_id, body, attachments } = params;
3915
4505
  const token = await getToken();
3916
- const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
4506
+ const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
3917
4507
  await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
3918
4508
  body,
3919
4509
  public: true,
@@ -4346,6 +4936,7 @@ const createUserTools = (ctx) => {
4346
4936
  //#region src/tools/index.ts
4347
4937
  const createAllTools = (ctx) => [
4348
4938
  ...createTicketTools(ctx),
4939
+ ...createRequestTools(ctx),
4349
4940
  ...createSearchTools(ctx),
4350
4941
  ...createHelpCenterTools(ctx),
4351
4942
  ...createUserTools(ctx)
@@ -4354,19 +4945,16 @@ const createAllTools = (ctx) => [
4354
4945
  //#region src/utils/validation.ts
4355
4946
  /**
4356
4947
  * Build a strict params parser for a tool's input schema, computing the strict
4357
- * schema and the valid-key list once (at proxy-dispatch construction) rather
4358
- * than per call.
4948
+ * schema and the valid-key list once at construction rather than per call.
4359
4949
  *
4360
4950
  * Zod objects default to `strip`, which silently drops unknown keys. That hid
4361
4951
  * #100: a caller passing `per_page` to list_tickets (whose parameter is
4362
- * `page_size`) had the key dropped, so `page_size` fell back to its default and
4363
- * a large unpaginated page came back. The returned parser rejects unknown keys
4364
- * and rewrites the raw Zod error into a message that names the offending keys
4365
- * and lists the valid parameters so a mistyped/misremembered name fails loudly.
4952
+ * `page_size`) had the key dropped, so a large unpaginated page came back. The
4953
+ * parser rejects unknown keys and names both the offending ones and the valid
4954
+ * parameters, so a misremembered name fails loudly.
4366
4955
  *
4367
- * Used on the proxy dispatch path (namespace/single modes), where this code
4368
- * owns the parse. In `all` mode the SDK validates against the strict schema we
4369
- * register and produces its own (also explicit) "Unrecognized key" message.
4956
+ * Used on the proxy dispatch path (namespace/single modes); in `all` mode the
4957
+ * SDK validates against the strict schema we register.
4370
4958
  */
4371
4959
  const createStrictParamsParser = (schema) => {
4372
4960
  const strict = schema.strict();
@@ -4383,17 +4971,15 @@ const createStrictParamsParser = (schema) => {
4383
4971
  //#region src/server.ts
4384
4972
  /**
4385
4973
  * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
4386
- * token (401). This lets the OAuth store drop the dead token so the next call
4387
- * refreshes/re-authenticates instead of replaying a revoked token. The callback
4388
- * is omitted only where there is nothing to invalidate (e.g. HTTP per-session
4389
- * bearer, owned by the client).
4974
+ * token (401), so the OAuth store drops the dead token instead of replaying it.
4975
+ * The callback is omitted only where there is nothing to invalidate (the HTTP
4976
+ * per-session bearer, owned by the client).
4390
4977
  *
4391
- * Client-visible behaviour on an in-flight revocation: the 401 is a *backstop*,
4392
- * not a transparent retry. The current call still surfaces the error; recovery
4393
- * happens on the *next* call, whose `getToken` sees the invalidated token and
4394
- * silently refreshes (or falls back to browser re-auth if the refresh token is
4395
- * also dead). Proactive refresh keeps this path rare — it only fires when a
4396
- * token is revoked between the pre-call refresh check and the request.
4978
+ * The 401 is a *backstop*, not a transparent retry: the current call still
4979
+ * surfaces the error, and recovery happens on the *next* one, whose `getToken`
4980
+ * refreshes silently (or falls back to browser re-auth). Proactive refresh keeps
4981
+ * this rare — it fires only for a token revoked between the pre-call check and
4982
+ * the request.
4397
4983
  */
4398
4984
  const runHandler = async (def, params, onUnauthorized) => {
4399
4985
  try {
@@ -4403,20 +4989,6 @@ const runHandler = async (def, params, onUnauthorized) => {
4403
4989
  throw err;
4404
4990
  }
4405
4991
  };
4406
- const NAMESPACE_LABELS = {
4407
- tickets: {
4408
- toolName: "zendesk_tickets",
4409
- title: "Zendesk Tickets"
4410
- },
4411
- help_center: {
4412
- toolName: "zendesk_help_center",
4413
- title: "Zendesk Help Center"
4414
- },
4415
- users: {
4416
- toolName: "zendesk_users",
4417
- title: "Zendesk Users"
4418
- }
4419
- };
4420
4992
  const summarizeDescription = (description) => {
4421
4993
  const idx = description.indexOf(". ");
4422
4994
  if (idx === -1) return description;
@@ -4500,8 +5072,9 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
4500
5072
  const filteredTools = filterTools(tools, {
4501
5073
  readOnly: config.readOnly,
4502
5074
  namespaces: config.namespaces,
4503
- tools: config.tools
4504
- }).filter((t) => config.promotedArticles !== false || t.name !== "list_promoted_articles");
5075
+ tools: config.tools,
5076
+ promotedArticles: config.promotedArticles
5077
+ });
4505
5078
  try {
4506
5079
  switch (config.mode) {
4507
5080
  case "all":
@@ -4621,6 +5194,10 @@ const TOOL_MODULES = [
4621
5194
  file: "tickets.ts",
4622
5195
  factory: "createTicketTools"
4623
5196
  },
5197
+ {
5198
+ file: "requests.ts",
5199
+ factory: "createRequestTools"
5200
+ },
4624
5201
  {
4625
5202
  file: "search.ts",
4626
5203
  factory: "createSearchTools"
@@ -4753,6 +5330,70 @@ const startDevServer = async (config, getToken, logger = silentLogger, onUnautho
4753
5330
  };
4754
5331
  /* v8 ignore stop */
4755
5332
  //#endregion
5333
+ //#region src/routing/print.ts
5334
+ const toolLine = (tool, indent) => `${indent}${tool.name}${tool.readOnly ? "" : " (write)"}`;
5335
+ const renderHeader = (config) => [
5336
+ `Mode: ${config.mode}`,
5337
+ `Namespaces: ${config.namespaces.join(", ")}`,
5338
+ `Read-only: ${config.readOnly ? "yes" : "no"}`,
5339
+ ...config.tools?.length ? [`Tool filter: ${config.tools.join(", ")}`] : []
5340
+ ].join(" | ");
5341
+ const renderAllMode = (tools) => [`${tools.length} tool(s) exposed individually:`, ...tools.map((tool) => toolLine(tool, " "))];
5342
+ const renderNamespaceMode = (tools) => {
5343
+ const grouped = groupByNamespace(tools);
5344
+ const lines = [`${grouped.size} proxy tool(s) exposed:`];
5345
+ for (const [namespace, nsTools] of grouped) {
5346
+ lines.push(` ${NAMESPACE_LABELS[namespace]?.toolName ?? namespace}`);
5347
+ lines.push(...nsTools.map((tool) => toolLine(tool, " - ")));
5348
+ }
5349
+ return lines;
5350
+ };
5351
+ const renderSingleMode = (tools) => [
5352
+ `1 proxy tool exposed, wrapping ${tools.length} operation(s):`,
5353
+ " zendesk",
5354
+ ...tools.map((tool) => toolLine(tool, " - "))
5355
+ ];
5356
+ const renderBody = (config, tools) => {
5357
+ switch (config.mode) {
5358
+ case "all": return renderAllMode(tools);
5359
+ case "namespace": return renderNamespaceMode(tools);
5360
+ case "single": return renderSingleMode(tools);
5361
+ default: {
5362
+ const unhandled = config.mode;
5363
+ throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
5364
+ }
5365
+ }
5366
+ };
5367
+ /**
5368
+ * Render the tool surface `config` resolves to, as `registerToolset` would
5369
+ * expose it, without starting a server or issuing a request -- the combination
5370
+ * of `--namespace`/`--tool`, `--mode` and `--read-only` is easier read than
5371
+ * predicted.
5372
+ *
5373
+ * It mirrors `registerToolset`'s `switch (config.mode)` rather than calling it,
5374
+ * which would need a live `McpServer`; tests pin this output against the names
5375
+ * the integration harness sees over the wire.
5376
+ *
5377
+ * Read-only is stated in the header, not as a `[RO]` name prefix: the server
5378
+ * puts that marker in a proxy's description, so `[RO] zendesk_tickets` would
5379
+ * name a tool no client sees.
5380
+ */
5381
+ const renderToolSurface = (config, tools) => {
5382
+ const filtered = filterTools(tools, {
5383
+ readOnly: config.readOnly,
5384
+ namespaces: config.namespaces,
5385
+ tools: config.tools,
5386
+ promotedArticles: config.promotedArticles
5387
+ });
5388
+ const header = renderHeader(config);
5389
+ if (filtered.length === 0) return `${header}\n\nNo tools exposed. Check --namespace / --tool / --read-only.`;
5390
+ return [
5391
+ header,
5392
+ "",
5393
+ ...renderBody(config, filtered)
5394
+ ].join("\n");
5395
+ };
5396
+ //#endregion
4756
5397
  //#region src/transports/http.ts
4757
5398
  const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
4758
5399
  "0.0.0.0",
@@ -5144,16 +5785,15 @@ const defaultRuntime = createRuntime(process);
5144
5785
  /**
5145
5786
  * Install the process's one shutdown path and return its trigger.
5146
5787
  *
5147
- * Registering a `SIGTERM` handler *removes* Node's default terminate, which
5148
- * makes the exit our responsibility: a cleanup that stalls on an in-flight
5149
- * request would otherwise leave a process SIGTERM cannot kill — the very
5150
- * symptom this exists to remove. Hence the watchdog, which is load-bearing
5151
- * rather than defensive, and the unconditional `exit` on every path.
5788
+ * Registering a `SIGTERM` handler *removes* Node's default terminate, making
5789
+ * the exit our responsibility: a cleanup stalled on an in-flight request would
5790
+ * leave a process SIGTERM cannot kill — the symptom this exists to remove.
5791
+ * Hence the watchdog and the unconditional `exit` on every path.
5152
5792
  *
5153
5793
  * The exit is explicit rather than a drained event loop because the OAuth
5154
5794
  * callback server (`auth/browser-oauth.ts`) is a listening socket that is not
5155
- * `unref()`'d: letting the loop drain would keep a disconnected session alive
5156
- * for up to the 5-minute auth timeout.
5795
+ * `unref()`'d: draining would keep a disconnected session alive for the
5796
+ * 5-minute auth timeout.
5157
5797
  */
5158
5798
  const installShutdown = (options) => {
5159
5799
  const { cleanup, logger, watchStdin, graceMs = SHUTDOWN_GRACE_MS } = options;
@@ -5205,6 +5845,14 @@ const connectStdio = async (config, tokenStore, logger) => {
5205
5845
  };
5206
5846
  const main = async () => {
5207
5847
  const config = loadConfig();
5848
+ if (config.printTools) {
5849
+ const tools = createAllTools({
5850
+ subdomain: config.subdomain,
5851
+ getToken: () => ""
5852
+ });
5853
+ console.log(renderToolSurface(config, tools));
5854
+ return;
5855
+ }
5208
5856
  const logger = createLogger(config.logLevel);
5209
5857
  if (config.transport === "stdio") {
5210
5858
  const tokenStore = buildStdioTokenStore(config, logger);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "mcpName": "io.github.fruggr/zendesk-mcp-server",
5
5
  "description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
6
6
  "type": "module",
@@ -69,13 +69,13 @@
69
69
  "engines": {
70
70
  "node": ">=20"
71
71
  },
72
- "packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
72
+ "packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "1.30.0",
75
75
  "cheerio": "1.2.0",
76
76
  "hast-util-to-html": "9.0.5",
77
77
  "hast-util-to-mdast": "10.1.2",
78
- "open": "11.0.1",
78
+ "open": "11.0.2",
79
79
  "rehype-parse": "9.0.1",
80
80
  "rehype-raw": "7.0.0",
81
81
  "rehype-remark": "10.0.1",
@@ -85,7 +85,7 @@
85
85
  "remark-rehype": "11.1.2",
86
86
  "remark-stringify": "11.0.0",
87
87
  "unified": "11.0.5",
88
- "zod": "4.5.4"
88
+ "zod": "4.6.2"
89
89
  },
90
90
  "devDependencies": {
91
91
  "@biomejs/biome": "2.5.12",