@fruggr/zendesk-mcp-server 2.20.2 → 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 +99 -7
  2. package/dist/index.js +806 -92
  3. package/package.json +4 -4
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`;
@@ -143,6 +144,48 @@ const getOAuthUrls = (subdomain) => ({
143
144
  tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
144
145
  });
145
146
  //#endregion
147
+ //#region src/auth/oauth-scopes.ts
148
+ /**
149
+ * The OAuth scope the server asks Zendesk for, and the one predicate that
150
+ * decides whether a cached token's grant is still good enough.
151
+ *
152
+ * Both scope strings live here rather than in `constants.ts` on purpose: they
153
+ * are behavioural strings whose mutants must be killed by assertions
154
+ * (`docs/decisions/mutation-testing.md`, "OAuth parameters and scopes"), and
155
+ * `src/auth/**` is inside the mutation scope while `constants.ts` is not.
156
+ * Keeping them next to the predicate that consumes them keeps both under the
157
+ * same gate.
158
+ */
159
+ const READ_SCOPE = "read";
160
+ const READ_WRITE_SCOPE = "read write";
161
+ const WHITESPACE = /\s+/;
162
+ const scopeTokens = (scope) => scope.split(WHITESPACE).filter((s) => s.length > 0);
163
+ /**
164
+ * The scope to request for a given tool surface. `--read-only` already filters
165
+ * every write tool out of the surface, so asking Zendesk for `write` on top of
166
+ * that would be requesting an authority the server cannot even exercise — and
167
+ * an OAuth client whose allowed scopes stop at `read` rejects the whole
168
+ * authorize request with `invalid_scope`, minting no token at all (#283).
169
+ */
170
+ const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
171
+ /**
172
+ * The same decision as a list, for the RFC 9728 / RFC 8414 `scopes_supported`
173
+ * metadata. Derived from `requestedScope` so what the HTTP transport advertises
174
+ * provably cannot drift from what the stdio flow requests.
175
+ */
176
+ const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
177
+ /**
178
+ * Whether a grant still covers what this process needs: a flat subset test.
179
+ * Coverage, not equality, so a broader token stays usable and two servers
180
+ * sharing a token file converge. A non-string `granted` is a pre-#283 record,
181
+ * i.e. `read write`. No scope hierarchy: granular scopes (#284) replace this.
182
+ */
183
+ const grantCovers = (granted, requested) => {
184
+ if (typeof granted !== "string") return true;
185
+ const held = new Set(scopeTokens(granted));
186
+ return scopeTokens(requested).every((token) => held.has(token));
187
+ };
188
+ //#endregion
146
189
  //#region src/auth/browser-oauth.ts
147
190
  const AUTH_TIMEOUT_MS = 3e5;
148
191
  /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
@@ -185,7 +228,7 @@ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier
185
228
  * open for up to the 5-minute timeout.
186
229
  */
187
230
  const startBrowserAuth = (config, logger = silentLogger) => {
188
- const { subdomain, oauthClientId } = config;
231
+ const { subdomain, oauthClientId, readOnly } = config;
189
232
  const { authorizeUrl: authorizeBase, tokenUrl } = getOAuthUrls(subdomain);
190
233
  const codeVerifier = generateCodeVerifier();
191
234
  const codeChallenge = generateCodeChallenge(codeVerifier);
@@ -308,7 +351,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
308
351
  response_type: "code",
309
352
  client_id: oauthClientId,
310
353
  redirect_uri: redirectUri,
311
- scope: "read write",
354
+ scope: requestedScope(readOnly),
312
355
  code_challenge: codeChallenge,
313
356
  code_challenge_method: "S256"
314
357
  });
@@ -497,8 +540,18 @@ const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */
497
540
  const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
498
541
  const createTokenStore = (config, logger = silentLogger) => {
499
542
  const tokenPath = resolveTokenPath(config.subdomain);
543
+ const requested = requestedScope(config.readOnly);
500
544
  let token = loadToken(tokenPath);
501
- if (token) logger.debug("oauth_token_loaded_from_disk");
545
+ if (token) {
546
+ if (grantCovers(token.scope, requested)) logger.debug("oauth_token_loaded_from_disk");
547
+ else {
548
+ logger.warn("oauth_token_scope_insufficient", {
549
+ requested,
550
+ granted: token.scope
551
+ });
552
+ token = void 0;
553
+ }
554
+ }
502
555
  let authorizeUrl;
503
556
  let starting;
504
557
  let refreshing;
@@ -507,12 +560,20 @@ const createTokenStore = (config, logger = silentLogger) => {
507
560
  const setToken = (accessToken, refreshToken) => {
508
561
  token = {
509
562
  accessToken,
510
- refreshToken
563
+ refreshToken,
564
+ scope: requested
511
565
  };
512
566
  probedUnknownExpiry = false;
513
567
  persist(token);
514
568
  };
515
569
  const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
570
+ const noteGrant = (granted) => {
571
+ if (!grantCovers(granted, requested)) logger.warn("oauth_token_grant_narrowed", {
572
+ requested,
573
+ granted
574
+ });
575
+ return granted;
576
+ };
516
577
  const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
517
578
  if (!current.refreshToken) return void 0;
518
579
  try {
@@ -524,7 +585,8 @@ const createTokenStore = (config, logger = silentLogger) => {
524
585
  token = {
525
586
  accessToken: result.access_token,
526
587
  refreshToken: result.refresh_token ?? current.refreshToken,
527
- expiresAt: expiryFrom(result.expires_in)
588
+ expiresAt: expiryFrom(result.expires_in),
589
+ scope: noteGrant(result.scope || current.scope)
528
590
  };
529
591
  probedUnknownExpiry = true;
530
592
  persist(token);
@@ -544,14 +606,16 @@ const createTokenStore = (config, logger = silentLogger) => {
544
606
  return startBrowserAuth({
545
607
  subdomain: config.subdomain,
546
608
  oauthClientId: config.oauthClientId,
547
- callbackPort: config.callbackPort
609
+ callbackPort: config.callbackPort,
610
+ readOnly: config.readOnly
548
611
  }, logger).then((started) => {
549
612
  authorizeUrl = started.authorizeUrl;
550
613
  started.tokenPromise.then((result) => {
551
614
  token = {
552
615
  accessToken: result.access_token,
553
616
  refreshToken: result.refresh_token,
554
- expiresAt: expiryFrom(result.expires_in)
617
+ expiresAt: expiryFrom(result.expires_in),
618
+ scope: noteGrant(result.scope || requested)
555
619
  };
556
620
  probedUnknownExpiry = true;
557
621
  persist(token);
@@ -593,7 +657,8 @@ const createTokenStore = (config, logger = silentLogger) => {
593
657
  token = {
594
658
  accessToken: token.accessToken,
595
659
  refreshToken: token.refreshToken,
596
- expiresAt: 0
660
+ expiresAt: 0,
661
+ scope: token.scope
597
662
  };
598
663
  persist(token);
599
664
  } else {
@@ -632,8 +697,24 @@ const LogLevel = z.enum([
632
697
  const Namespace = z.enum([
633
698
  "tickets",
634
699
  "help_center",
635
- "users"
700
+ "users",
701
+ "requests"
636
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
+ ];
637
718
  const Transport = z.enum(["stdio", "http"]);
638
719
  const ConfigSchema = z.object({
639
720
  subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
@@ -641,7 +722,18 @@ const ConfigSchema = z.object({
641
722
  logLevel: LogLevel,
642
723
  mode: ToolMode,
643
724
  readOnly: z.boolean(),
644
- 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]),
645
737
  tools: z.array(z.string()).optional(),
646
738
  /**
647
739
  * Whether to expose the Help Center structural context (the `instructions`
@@ -692,6 +784,14 @@ const ConfigSchema = z.object({
692
784
  * the "Dev mode" section of docs/configuration.md.
693
785
  */
694
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),
695
795
  transport: Transport,
696
796
  host: z.string().min(1),
697
797
  port: z.number().int().min(0).max(65535),
@@ -741,7 +841,8 @@ const CLI_OPTIONS = {
741
841
  "read-only": { type: "boolean" },
742
842
  "no-topology": { type: "boolean" },
743
843
  "no-promoted-articles": { type: "boolean" },
744
- dev: { type: "boolean" }
844
+ dev: { type: "boolean" },
845
+ "print-tools": { type: "boolean" }
745
846
  };
746
847
  new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
747
848
  const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
@@ -759,7 +860,8 @@ const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
759
860
  ["read-only", { readOnly: true }],
760
861
  ["no-topology", { topology: false }],
761
862
  ["no-promoted-articles", { promotedArticles: false }],
762
- ["dev", { dev: true }]
863
+ ["dev", { dev: true }],
864
+ ["print-tools", { printTools: true }]
763
865
  ]);
764
866
  const parseCliArgs = (args) => {
765
867
  const { values, positionals } = parseArgs({
@@ -798,6 +900,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
798
900
  const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
799
901
  const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
800
902
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
903
+ const namespaces = cli.namespaces ?? (cli.tools?.length ? [...Namespace.options] : void 0);
801
904
  const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
802
905
  const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
803
906
  return ConfigSchema.parse({
@@ -806,12 +909,13 @@ const loadConfig = (argv = process.argv.slice(2)) => {
806
909
  logLevel: cli.logLevel ?? requireNonEmptyEnv("LOG_LEVEL") ?? "info",
807
910
  mode,
808
911
  readOnly: cli.readOnly ?? false,
809
- namespaces: cli.namespaces,
912
+ namespaces,
810
913
  tools: cli.tools,
811
914
  topology: cli.topology ?? true,
812
915
  promotedArticles: cli.promotedArticles ?? true,
813
916
  hcResourceScheme,
814
917
  dev: cli.dev ?? false,
918
+ printTools: cli.printTools ?? false,
815
919
  callbackPort,
816
920
  ...resolveTransportSettings(cli)
817
921
  });
@@ -1323,6 +1427,25 @@ const formatComment = (comment, authors) => {
1323
1427
  lines.push("", comment.body);
1324
1428
  return lines.join("\n");
1325
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
+ };
1326
1449
  const formatTagDiff = (before, after) => {
1327
1450
  const b = new Set(Array.isArray(before) ? before.map(String) : []);
1328
1451
  const a = new Set(Array.isArray(after) ? after.map(String) : []);
@@ -1557,15 +1680,13 @@ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
1557
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.");
1558
1681
  };
1559
1682
  /**
1560
- * Build an article-resources provider. `listPromoted` holds a memoized-promise
1561
- * cache (TTL `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list`
1562
- * 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
1563
1686
  * `createTopologyProvider`, the cache is PER SESSION and must NOT be hoisted to
1564
- * module scope — in HTTP mode this provider is instantiated per session, so a
1565
- * shared cache would leak one caller's data to another. `getToken` is resolved
1566
- * lazily at call time (never at construction) so connecting never triggers the
1567
- * OAuth/PKCE flow. A 401 notifies `onUnauthorized` (stdio OAuth) to drop the
1568
- * 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.
1569
1690
  */
1570
1691
  const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized) => {
1571
1692
  let cached;
@@ -1834,10 +1955,19 @@ const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
1834
1955
  };
1835
1956
  //#endregion
1836
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
+ */
1837
1966
  const filterTools = (allTools, options) => allTools.filter((tool) => {
1838
1967
  if (options.readOnly && !tool.readOnly) return false;
1839
1968
  if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
1840
1969
  if (options.tools?.length && !options.tools.includes(tool.name)) return false;
1970
+ if (options.promotedArticles === false && tool.name === "list_promoted_articles") return false;
1841
1971
  return true;
1842
1972
  });
1843
1973
  const groupByNamespace = (tools) => {
@@ -1849,6 +1979,34 @@ const groupByNamespace = (tools) => {
1849
1979
  }
1850
1980
  return grouped;
1851
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
+ };
1852
2010
  //#endregion
1853
2011
  //#region src/utils/article-order.ts
1854
2012
  const hasPositionInversion = (order) => {
@@ -3117,6 +3275,526 @@ const createHelpCenterTools = (ctx) => {
3117
3275
  ];
3118
3276
  };
3119
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
3120
3798
  //#region src/tools/search.ts
3121
3799
  const formatSearchResult = (result) => {
3122
3800
  const lines = [`## [${result["result_type"]}] #${result["id"]}`];
@@ -3479,30 +4157,6 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
3479
4157
  };
3480
4158
  const createTicketTools = (ctx) => {
3481
4159
  const { subdomain, getToken } = ctx;
3482
- const attachmentSchema = z.object({
3483
- file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
3484
- file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
3485
- abort: true,
3486
- 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.`
3487
- }).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.`),
3488
- content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
3489
- });
3490
- const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
3491
- const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
3492
- if (total > 10420224) refinement.addIssue({
3493
- code: "custom",
3494
- message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
3495
- });
3496
- }).optional().describe(description);
3497
- const uploadAttachments = async (token, files) => {
3498
- let uploadToken;
3499
- for (const file of files) {
3500
- const { upload } = await zendeskUpload(subdomain, token, file.file_name, Buffer.from(file.file_base64, "base64"), file.content_type, uploadToken);
3501
- uploadToken = upload.token;
3502
- }
3503
- return uploadToken;
3504
- };
3505
- const formatAttachmentSuffix = (count) => count ? ` with ${count} attachment(s)` : "";
3506
4160
  return [
3507
4161
  {
3508
4162
  name: "get_ticket",
@@ -3817,7 +4471,7 @@ const createTicketTools = (ctx) => {
3817
4471
  handler: async (params) => {
3818
4472
  const { ticket_id, body, attachments } = params;
3819
4473
  const token = await getToken();
3820
- const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
4474
+ const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
3821
4475
  await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
3822
4476
  body,
3823
4477
  public: false,
@@ -3849,7 +4503,7 @@ const createTicketTools = (ctx) => {
3849
4503
  handler: async (params) => {
3850
4504
  const { ticket_id, body, attachments } = params;
3851
4505
  const token = await getToken();
3852
- const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
4506
+ const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
3853
4507
  await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
3854
4508
  body,
3855
4509
  public: true,
@@ -4282,6 +4936,7 @@ const createUserTools = (ctx) => {
4282
4936
  //#region src/tools/index.ts
4283
4937
  const createAllTools = (ctx) => [
4284
4938
  ...createTicketTools(ctx),
4939
+ ...createRequestTools(ctx),
4285
4940
  ...createSearchTools(ctx),
4286
4941
  ...createHelpCenterTools(ctx),
4287
4942
  ...createUserTools(ctx)
@@ -4290,19 +4945,16 @@ const createAllTools = (ctx) => [
4290
4945
  //#region src/utils/validation.ts
4291
4946
  /**
4292
4947
  * Build a strict params parser for a tool's input schema, computing the strict
4293
- * schema and the valid-key list once (at proxy-dispatch construction) rather
4294
- * than per call.
4948
+ * schema and the valid-key list once at construction rather than per call.
4295
4949
  *
4296
4950
  * Zod objects default to `strip`, which silently drops unknown keys. That hid
4297
4951
  * #100: a caller passing `per_page` to list_tickets (whose parameter is
4298
- * `page_size`) had the key dropped, so `page_size` fell back to its default and
4299
- * a large unpaginated page came back. The returned parser rejects unknown keys
4300
- * and rewrites the raw Zod error into a message that names the offending keys
4301
- * 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.
4302
4955
  *
4303
- * Used on the proxy dispatch path (namespace/single modes), where this code
4304
- * owns the parse. In `all` mode the SDK validates against the strict schema we
4305
- * 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.
4306
4958
  */
4307
4959
  const createStrictParamsParser = (schema) => {
4308
4960
  const strict = schema.strict();
@@ -4319,17 +4971,15 @@ const createStrictParamsParser = (schema) => {
4319
4971
  //#region src/server.ts
4320
4972
  /**
4321
4973
  * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
4322
- * token (401). This lets the OAuth store drop the dead token so the next call
4323
- * refreshes/re-authenticates instead of replaying a revoked token. The callback
4324
- * is omitted only where there is nothing to invalidate (e.g. HTTP per-session
4325
- * 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).
4326
4977
  *
4327
- * Client-visible behaviour on an in-flight revocation: the 401 is a *backstop*,
4328
- * not a transparent retry. The current call still surfaces the error; recovery
4329
- * happens on the *next* call, whose `getToken` sees the invalidated token and
4330
- * silently refreshes (or falls back to browser re-auth if the refresh token is
4331
- * also dead). Proactive refresh keeps this path rare — it only fires when a
4332
- * 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.
4333
4983
  */
4334
4984
  const runHandler = async (def, params, onUnauthorized) => {
4335
4985
  try {
@@ -4339,20 +4989,6 @@ const runHandler = async (def, params, onUnauthorized) => {
4339
4989
  throw err;
4340
4990
  }
4341
4991
  };
4342
- const NAMESPACE_LABELS = {
4343
- tickets: {
4344
- toolName: "zendesk_tickets",
4345
- title: "Zendesk Tickets"
4346
- },
4347
- help_center: {
4348
- toolName: "zendesk_help_center",
4349
- title: "Zendesk Help Center"
4350
- },
4351
- users: {
4352
- toolName: "zendesk_users",
4353
- title: "Zendesk Users"
4354
- }
4355
- };
4356
4992
  const summarizeDescription = (description) => {
4357
4993
  const idx = description.indexOf(". ");
4358
4994
  if (idx === -1) return description;
@@ -4436,8 +5072,9 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
4436
5072
  const filteredTools = filterTools(tools, {
4437
5073
  readOnly: config.readOnly,
4438
5074
  namespaces: config.namespaces,
4439
- tools: config.tools
4440
- }).filter((t) => config.promotedArticles !== false || t.name !== "list_promoted_articles");
5075
+ tools: config.tools,
5076
+ promotedArticles: config.promotedArticles
5077
+ });
4441
5078
  try {
4442
5079
  switch (config.mode) {
4443
5080
  case "all":
@@ -4557,6 +5194,10 @@ const TOOL_MODULES = [
4557
5194
  file: "tickets.ts",
4558
5195
  factory: "createTicketTools"
4559
5196
  },
5197
+ {
5198
+ file: "requests.ts",
5199
+ factory: "createRequestTools"
5200
+ },
4560
5201
  {
4561
5202
  file: "search.ts",
4562
5203
  factory: "createSearchTools"
@@ -4689,6 +5330,70 @@ const startDevServer = async (config, getToken, logger = silentLogger, onUnautho
4689
5330
  };
4690
5331
  /* v8 ignore stop */
4691
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
4692
5397
  //#region src/transports/http.ts
4693
5398
  const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
4694
5399
  "0.0.0.0",
@@ -4791,12 +5496,13 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
4791
5496
  const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
4792
5497
  const issuer = `https://${config.subdomain}.zendesk.com`;
4793
5498
  const resource = resolveResourceUrl(config, logger);
5499
+ const scopes = () => supportedScopes(config.readOnly);
4794
5500
  return {
4795
5501
  protectedResource: {
4796
5502
  authorization_servers: [issuer],
4797
5503
  resource,
4798
5504
  bearer_methods_supported: ["header"],
4799
- scopes_supported: ["read", "write"]
5505
+ scopes_supported: scopes()
4800
5506
  },
4801
5507
  authorizationServer: {
4802
5508
  issuer,
@@ -4806,7 +5512,7 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
4806
5512
  grant_types_supported: ["authorization_code", "refresh_token"],
4807
5513
  code_challenge_methods_supported: ["S256"],
4808
5514
  token_endpoint_auth_methods_supported: ["none"],
4809
- scopes_supported: ["read", "write"]
5515
+ scopes_supported: scopes()
4810
5516
  }
4811
5517
  };
4812
5518
  };
@@ -5079,16 +5785,15 @@ const defaultRuntime = createRuntime(process);
5079
5785
  /**
5080
5786
  * Install the process's one shutdown path and return its trigger.
5081
5787
  *
5082
- * Registering a `SIGTERM` handler *removes* Node's default terminate, which
5083
- * makes the exit our responsibility: a cleanup that stalls on an in-flight
5084
- * request would otherwise leave a process SIGTERM cannot kill — the very
5085
- * symptom this exists to remove. Hence the watchdog, which is load-bearing
5086
- * 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.
5087
5792
  *
5088
5793
  * The exit is explicit rather than a drained event loop because the OAuth
5089
5794
  * callback server (`auth/browser-oauth.ts`) is a listening socket that is not
5090
- * `unref()`'d: letting the loop drain would keep a disconnected session alive
5091
- * 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.
5092
5797
  */
5093
5798
  const installShutdown = (options) => {
5094
5799
  const { cleanup, logger, watchStdin, graceMs = SHUTDOWN_GRACE_MS } = options;
@@ -5129,7 +5834,8 @@ const installShutdown = (options) => {
5129
5834
  const buildStdioTokenStore = (config, logger) => createTokenStore({
5130
5835
  subdomain: config.subdomain,
5131
5836
  oauthClientId: config.oauthClientId,
5132
- callbackPort: config.callbackPort
5837
+ callbackPort: config.callbackPort,
5838
+ readOnly: config.readOnly
5133
5839
  }, logger);
5134
5840
  const connectStdio = async (config, tokenStore, logger) => {
5135
5841
  if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
@@ -5139,6 +5845,14 @@ const connectStdio = async (config, tokenStore, logger) => {
5139
5845
  };
5140
5846
  const main = async () => {
5141
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
+ }
5142
5856
  const logger = createLogger(config.logLevel);
5143
5857
  if (config.transport === "stdio") {
5144
5858
  const tokenStore = buildStdioTokenStore(config, logger);