@fruggr/zendesk-mcp-server 1.9.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,9 +13,9 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that co
13
13
 
14
14
  Most Zendesk integrations use a shared admin API key, giving every user full access to every ticket. This server takes a different approach:
15
15
 
16
- - **Per-user authentication by default** — In both transports, the default is OAuth 2.1 PKCE: each user authenticates with their own Zendesk credentials, so the LLM sees exactly what the user is allowed to see. A static API-token escape hatch is documented below for stdio-only CI / headless contexts; it's refused at boot in HTTP mode.
16
+ - **Per-user authentication, OAuth-only** — In both transports, auth is OAuth 2.1 PKCE: each user authenticates with their own Zendesk credentials, so the LLM sees exactly what the user is allowed to see. Static API tokens are deliberately **not** supported (see [below](#what-this-server-does-not-do)).
17
17
  - **Two deployment shapes, same auth story** — Run it on your laptop as a stdio MCP server (Claude Desktop / Claude Code / VS Code) or deploy it as a private remote MCP server with one user, one Zendesk session per HTTP request.
18
- - **Context-friendly tool modes** — Expose 37 individual tools, 3 namespace proxies, or a single unified tool. Choose the mode that fits your LLM's context budget.
18
+ - **Context-friendly tool modes** — Expose every operation as its own tool, group them into namespace proxies, or collapse to a single unified tool. Tools are segmented into namespaces you can selectively enable, so each context loads only the surface it needs.
19
19
  - **Section-based article editing** — For large Help Center articles, read and rewrite one section at a time (parsed by h1/h2/h3 headings) instead of shuffling the full HTML body through the LLM. Reduces tokens by 10–100× on targeted edits.
20
20
  - **Read-only mode** — Restrict the server to read operations only, ideal for assistants that should never modify data.
21
21
  - **Lean stack** — Built on the official `@modelcontextprotocol/sdk` plus `zod`.
@@ -34,14 +34,23 @@ Most Zendesk integrations use a shared admin API key, giving every user full acc
34
34
  **Look elsewhere when:**
35
35
 
36
36
  - You need Zendesk products outside Support & Guide (e.g. Talk, Explore analytics, Sell) — those endpoints aren't covered.
37
- - You need a single shared service account for all usersthat's the opposite of this server's per-user OAuth model (the API-token escape hatch exists for stdio CI only, and is refused at boot in HTTP).
37
+ - You need a single shared service account, or static API-token auth — this server doesn't support either, by design (see [What this server does *not* do](#what-this-server-does-not-do)).
38
+
39
+ ## What this server does *not* do
40
+
41
+ **No API-token authentication.** This server is OAuth 2.1 PKCE only — there is no `ZENDESK_EMAIL` + `ZENDESK_API_TOKEN` (Basic auth) mode, in any transport. This is a deliberate design choice:
42
+
43
+ - **API tokens are insufficiently secure.** A Zendesk API token is a long-lived, static, shared secret that carries the full rights of the issuing user — no per-user scoping, no short expiry, no per-user consent or revocation. OAuth 2.1 PKCE issues per-user, revocable tokens instead, so the LLM only ever sees what the authenticated user is allowed to see.
44
+ - **API tokens don't scale.** A single static credential can't attribute actions to individual users or be revoked granularly, and it makes a multi-user remote deployment unsafe (in HTTP it would expose the issuing user's rights to every caller). OAuth scales naturally: each MCP client carries its own user's token.
45
+
46
+ If you specifically need an API-token / service-account mode (e.g. headless CI with a shared account), use one of the other Zendesk MCP servers that support it — see [Inspiration & related projects](#inspiration--related-projects).
38
47
 
39
48
  ## Use cases
40
49
 
41
50
  | Persona | Transport | Auth | Quick start |
42
51
  |---------|-----------|------|-------------|
43
- | **Run it on your laptop** — single user, plugged into Claude Desktop / Claude Code / VS Code | `stdio` (default) | OAuth 2.1 PKCE in your browser (or API token for CI) | [Quick start: local](#quick-start-local-stdio) |
44
- | **Deploy a private remote MCP server** — one server per Zendesk account, each MCP client carries its own user's OAuth token | `http` | Per-user OAuth 2.1 PKCE bearer in `Authorization:` header; API token refused | [Quick start: remote](#quick-start-remote-http) |
52
+ | **Run it on your laptop** — single user, plugged into Claude Desktop / Claude Code / VS Code | `stdio` (default) | OAuth 2.1 PKCE in your browser | [Quick start: local](#quick-start-local-stdio) |
53
+ | **Deploy a private remote MCP server** — one server per Zendesk account, each MCP client carries its own user's OAuth token | `http` | Per-user OAuth 2.1 PKCE bearer in `Authorization:` header | [Quick start: remote](#quick-start-remote-http) |
45
54
 
46
55
  ## Tool modes
47
56
 
@@ -49,13 +58,13 @@ The server registers tools in one of three modes, controlled by `--mode`:
49
58
 
50
59
  | Mode | Tools exposed | Best for |
51
60
  |------|--------------|----------|
52
- | **`all`** | 37 individual tools (`get_ticket`, `search_articles`, ...) | Clients with good tool selection, full granularity |
53
- | **`namespace`** (default) | 3 proxy tools (`zendesk_tickets`, `zendesk_help_center`, `zendesk_users`) | Balanced context usage, grouped operations |
54
- | **`single`** | 1 proxy tool (`zendesk`) | Minimal context footprint, single entry point |
61
+ | **`all`** | Every operation as its own tool (`get_ticket`, `search_articles`, ...) | Clients with good tool selection, full granularity |
62
+ | **`namespace`** (default) | One proxy tool per namespace (`zendesk_tickets`, `zendesk_help_center`, `zendesk_users`) | Balanced context usage, grouped operations |
63
+ | **`single`** | A single proxy tool (`zendesk`) | Minimal context footprint, single entry point |
55
64
 
56
65
  In `namespace` and `single` modes, the proxy tool accepts `{ "operation": "<tool_name>", "params": { ... } }` and dispatches to the appropriate handler after validating params through the original Zod schema. Proxy descriptions include only the first sentence of each sub-operation to stay compact; the full schema is applied when the operation is actually called.
57
66
 
58
- > **Tip:** The `single` mode is particularly useful for models with limited tool slots — one tool handles all 36 operations.
67
+ > **Tip:** The `single` mode is particularly useful for models with limited tool slots — one tool handles every operation.
59
68
 
60
69
  ### Scoping the surface
61
70
 
@@ -74,7 +83,7 @@ zendesk-mcp-server acme --namespace tickets
74
83
  ## Available tools
75
84
 
76
85
  <details>
77
- <summary><strong>Tickets</strong> (10 tools)</summary>
86
+ <summary><strong>Tickets</strong></summary>
78
87
 
79
88
  | Tool | Description | Mode |
80
89
  |------|-------------|------|
@@ -92,7 +101,7 @@ zendesk-mcp-server acme --namespace tickets
92
101
  </details>
93
102
 
94
103
  <details>
95
- <summary><strong>Help Center</strong> (21 tools)</summary>
104
+ <summary><strong>Help Center</strong></summary>
96
105
 
97
106
  | Tool | Description | Mode |
98
107
  |------|-------------|------|
@@ -121,7 +130,7 @@ zendesk-mcp-server acme --namespace tickets
121
130
  </details>
122
131
 
123
132
  <details>
124
- <summary><strong>Users & Organizations</strong> (5 tools)</summary>
133
+ <summary><strong>Users & Organizations</strong></summary>
125
134
 
126
135
  | Tool | Description | Mode |
127
136
  |------|-------------|------|
@@ -134,7 +143,7 @@ zendesk-mcp-server acme --namespace tickets
134
143
  </details>
135
144
 
136
145
  <details>
137
- <summary><strong>Search</strong> (1 tool)</summary>
146
+ <summary><strong>Search</strong></summary>
138
147
 
139
148
  | Tool | Description | Mode |
140
149
  |------|-------------|------|
@@ -202,8 +211,6 @@ browser sign-in.
202
211
  > `--callback-port`) to a free port — remember to register the matching
203
212
  > `http://localhost:<port>/callback` redirect URL in your Zendesk OAuth client.
204
213
 
205
- > **API token escape hatch (stdio only).** For headless/CI environments where a browser is unavailable, set `ZENDESK_EMAIL` + `ZENDESK_API_TOKEN` (generate the token in **Admin Center → Apps and integrations → APIs → Zendesk API → Token Access**). The MCP server then uses Basic auth instead of starting the OAuth flow. This mode is **refused at boot in HTTP** because a shared static credential would expose every caller to the issuing user's rights.
206
-
207
214
  ### MCP client wiring
208
215
 
209
216
  <details>
@@ -428,7 +435,7 @@ Options:
428
435
  **Examples:**
429
436
 
430
437
  ```bash
431
- # Local single-tool mode — minimal context, all 37 operations in one tool
438
+ # Local single-tool mode — minimal context, every operation in one tool
432
439
  zendesk-mcp-server acme --mode single
433
440
 
434
441
  # Read-only tickets only
@@ -450,8 +457,6 @@ zendesk-mcp-server acme --transport http --port 8080 \
450
457
  | `ZENDESK_OAUTH_CLIENT_ID` | no | `<subdomain>_zendesk` | OAuth client identifier |
451
458
  | `ZENDESK_OAUTH_CALLBACK_PORT` | no | `27439` | Local port for the OAuth browser callback (also `--callback-port`). Must match the redirect URL registered in Zendesk. **stdio only**. |
452
459
  | `ZENDESK_TOKEN_FILE` | no | OS config dir | Path to the persisted OAuth token file (`0600`). |
453
- | `ZENDESK_EMAIL` | stdio API-token only | — | Agent email for Basic auth — **refused in HTTP** |
454
- | `ZENDESK_API_TOKEN` | stdio API-token only | — | Zendesk API token — **refused in HTTP** |
455
460
  | `TRANSPORT` | no | `stdio` | `stdio` or `http` |
456
461
  | `HOST` | no | `0.0.0.0` | HTTP bind host |
457
462
  | `PORT` | no | `3000` | HTTP bind port (`0` to let the OS pick) |
@@ -459,7 +464,7 @@ zendesk-mcp-server acme --transport http --port 8080 \
459
464
  | `CORS_ORIGIN` | no | — | Comma-separated browser origins added to the default CORS allowlist |
460
465
  | `LOG_LEVEL` | no | `info` | Log verbosity (`debug` surfaces the full OAuth flow trace) |
461
466
 
462
- In stdio, if both `ZENDESK_EMAIL` and `ZENDESK_API_TOKEN` are set, the server uses API token auth; otherwise it uses OAuth 2.1 PKCE. In HTTP mode, API token credentials are refused at boot only per-user OAuth 2.1 PKCE is accepted. Full API-token setup is documented in [`docs/api-token-stdio.md`](docs/api-token-stdio.md).
467
+ The server uses per-user OAuth 2.1 PKCE for every transport (local stdio and remote HTTP). There is no static API-token mode see [What this server does *not* do](#what-this-server-does-not-do).
463
468
 
464
469
  ## Troubleshooting
465
470
 
@@ -567,9 +572,10 @@ Versions follow [SemVer](https://semver.org/) and are calculated **automatically
567
572
  ## FAQ
568
573
 
569
574
  **Do I need a Zendesk admin API key?**
570
- No. The default OAuth 2.1 PKCE flow means each user authenticates with their own
571
- credentials and the server acts with exactly their permissions. API-token auth is
572
- available for headless/CI use (see [Authentication](#authentication)).
575
+ No — and the server doesn't support one. The OAuth 2.1 PKCE flow means each user
576
+ authenticates with their own credentials and the server acts with exactly their
577
+ permissions. Static API tokens are intentionally unsupported (see
578
+ [What this server does *not* do](#what-this-server-does-not-do)).
573
579
 
574
580
  **Which Zendesk products are supported?**
575
581
  Zendesk Support (tickets, users, organizations) and the Help Center / Guide
package/dist/index.js CHANGED
@@ -21,16 +21,6 @@ import remarkStringify from "remark-stringify";
21
21
  import { unified } from "unified";
22
22
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
23
23
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
- //#region src/auth/api-token.ts
25
- /**
26
- * API token authentication for stdio transport.
27
- * Uses Basic auth: base64(email/token:api_token)
28
- */
29
- const buildBasicAuthHeader = (email, apiToken) => {
30
- const credentials = `${email}/token:${apiToken}`;
31
- return `Basic ${Buffer.from(credentials).toString("base64")}`;
32
- };
33
- //#endregion
34
24
  //#region src/utils/logger.ts
35
25
  const SEVERITY = {
36
26
  debug: 0,
@@ -571,8 +561,6 @@ const Transport = z.enum(["stdio", "http"]);
571
561
  const ConfigSchema = z.object({
572
562
  subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
573
563
  oauthClientId: z.string().min(1),
574
- zendeskEmail: z.string().optional(),
575
- zendeskApiToken: z.string().optional(),
576
564
  logLevel: LogLevel,
577
565
  mode: ToolMode,
578
566
  readOnly: z.boolean(),
@@ -657,14 +645,9 @@ const loadConfig = (argv = process.argv.slice(2)) => {
657
645
  const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
658
646
  const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
659
647
  const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
660
- const zendeskEmail = process.env["ZENDESK_EMAIL"];
661
- const zendeskApiToken = process.env["ZENDESK_API_TOKEN"];
662
- if (transport === "http" && zendeskEmail && zendeskApiToken) throw new Error("API token authentication (ZENDESK_EMAIL + ZENDESK_API_TOKEN) is not supported in HTTP mode. HTTP mode requires per-user OAuth 2.1 PKCE - unset these variables and configure your MCP client to perform the OAuth flow against Zendesk.");
663
648
  return ConfigSchema.parse({
664
649
  subdomain,
665
650
  oauthClientId,
666
- zendeskEmail,
667
- zendeskApiToken,
668
651
  logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
669
652
  mode,
670
653
  readOnly: cli.readOnly ?? false,
@@ -702,7 +685,7 @@ var ZendeskApiError = class ZendeskApiError extends Error {
702
685
  }
703
686
  }
704
687
  };
705
- const buildAuthHeader = (token) => token.startsWith("Basic ") ? token : `Bearer ${token}`;
688
+ const buildAuthHeader = (token) => `Bearer ${token}`;
706
689
  const buildUrl = (base, path, params) => {
707
690
  const url = new URL(`${base}${path}`);
708
691
  if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
@@ -1080,11 +1063,11 @@ const createHelpCenterTools = (ctx) => {
1080
1063
  namespace: "help_center",
1081
1064
  readOnly: true,
1082
1065
  title: "List Help Center Categories",
1083
- description: "List all Help Center categories. Optionally filter by locale.",
1066
+ description: "List all Help Center categories. Categories are the top level of the Guide hierarchy (category → section → article); each entry includes its id, name and locale. Results are cursor-paginated. Pair a returned category id with list_sections to drill down, then list_articles to reach articles. Pass a locale to read category names in that translation.",
1084
1067
  inputSchema: z.object({
1085
- locale: z.string().optional(),
1086
- page_size: z.number().int().min(1).max(100).default(100),
1087
- cursor: z.string().optional()
1068
+ locale: z.string().optional().describe("Locale for category names (e.g., \"en-us\", \"fr\"). Defaults to the Help Center default locale."),
1069
+ page_size: z.number().int().min(1).max(100).default(100).describe("Categories per page (1-100, default 100)."),
1070
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
1088
1071
  }),
1089
1072
  annotations: {
1090
1073
  readOnlyHint: true,
@@ -1106,12 +1089,12 @@ const createHelpCenterTools = (ctx) => {
1106
1089
  namespace: "help_center",
1107
1090
  readOnly: true,
1108
1091
  title: "List Help Center Sections",
1109
- description: "List sections, optionally filtered by category ID and locale.",
1092
+ description: "List Help Center sections. Sections are the middle level of the Guide hierarchy (category section → article) and group related articles; each entry includes its id, name, category_id and locale. Results are cursor-paginated. Pass category_id to list only one category's sections (ids come from list_categories), then use a section id with list_articles. Pass a locale to read section names in that translation.",
1110
1093
  inputSchema: z.object({
1111
- category_id: z.number().int().optional(),
1112
- locale: z.string().optional(),
1113
- page_size: z.number().int().min(1).max(100).default(100),
1114
- cursor: z.string().optional()
1094
+ category_id: z.number().int().optional().describe("Restrict to sections of this category (id from list_categories). Omit to list every section."),
1095
+ locale: z.string().optional().describe("Locale for section names (e.g., \"en-us\", \"fr\"). Defaults to the Help Center default locale."),
1096
+ page_size: z.number().int().min(1).max(100).default(100).describe("Sections per page (1-100, default 100)."),
1097
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
1115
1098
  }),
1116
1099
  annotations: {
1117
1100
  readOnlyHint: true,
@@ -1206,13 +1189,13 @@ const createHelpCenterTools = (ctx) => {
1206
1189
  namespace: "help_center",
1207
1190
  readOnly: false,
1208
1191
  title: "Create Article Translation",
1209
- description: "Create a translation for an existing article in a specific locale.",
1192
+ description: "Create a translation for an existing article in a specific locale. The article must already exist (create it with create_article); this adds a new localized version and returns the created translation (locale, title, draft state). The target locale must not already have a translation — use update_article_translation to modify an existing one, and list_article_translations to see which locales exist. Provide the full HTML body.",
1210
1193
  inputSchema: z.object({
1211
- article_id: z.number().int(),
1194
+ article_id: z.number().int().describe("ID of the existing article to translate."),
1212
1195
  locale: z.string().describe("Target locale (e.g., \"fr\", \"de\")"),
1213
- title: z.string().min(1),
1196
+ title: z.string().min(1).describe("Translated article title."),
1214
1197
  body: z.string().min(1).describe("Translated body (HTML)"),
1215
- draft: z.boolean().default(false)
1198
+ draft: z.boolean().default(false).describe("Create the translation as a draft (not visible to end users). Defaults to false (published).")
1216
1199
  }),
1217
1200
  annotations: {
1218
1201
  readOnlyHint: false,
@@ -1374,8 +1357,8 @@ const createHelpCenterTools = (ctx) => {
1374
1357
  namespace: "help_center",
1375
1358
  readOnly: false,
1376
1359
  title: "Create Content Tag",
1377
- description: "Create a new content tag for Guide articles.",
1378
- inputSchema: z.object({ name: z.string().min(1).describe("Content tag name") }),
1360
+ description: "Create a new content tag for Guide articles. Content tags are end-user visible labels that help readers discover related articles; this returns the created tag with its id. Check list_content_tags first to avoid duplicates, then attach the new id via the content_tag_ids parameter of create_article or update_article. For internal search-ranking labels that are not shown to end users, use article labels (list_labels) instead.",
1361
+ inputSchema: z.object({ name: z.string().min(1).describe("Content tag name as shown to end users (e.g., \"billing\", \"getting-started\").") }),
1379
1362
  annotations: {
1380
1363
  readOnlyHint: false,
1381
1364
  destructiveHint: false,
@@ -1436,8 +1419,8 @@ const createHelpCenterTools = (ctx) => {
1436
1419
  namespace: "help_center",
1437
1420
  readOnly: true,
1438
1421
  title: "List Article Attachments",
1439
- description: "List all attachments for an article.",
1440
- inputSchema: z.object({ article_id: z.number().int().describe("Article ID") }),
1422
+ description: "List all attachments for an article. Returns attachment metadata only (id, file name, content type, size, URL), not the file bytes; both inline and block attachments are included. This is for Help Center articles — for attachments on support tickets use get_ticket_attachments instead. Upload new files with create_article_attachment.",
1423
+ inputSchema: z.object({ article_id: z.number().int().describe("ID of the Help Center article whose attachments to list.") }),
1441
1424
  annotations: {
1442
1425
  readOnlyHint: true,
1443
1426
  destructiveHint: false,
@@ -1868,7 +1851,7 @@ const createTicketTools = (ctx) => {
1868
1851
  namespace: "tickets",
1869
1852
  readOnly: false,
1870
1853
  title: "Create Zendesk Ticket",
1871
- description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags.",
1854
+ description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags. The description becomes the first public comment of the ticket, and the new ticket id is returned. After creation, use update_ticket to change status or assignee, add_public_comment or add_private_note to reply, and manage_tags to adjust tags. Look up valid assignee_id / group_id and custom field ids via search_users or your Zendesk admin settings.",
1872
1855
  inputSchema: z.object({
1873
1856
  subject: z.string().min(1).describe("Ticket subject"),
1874
1857
  description: z.string().min(1).describe("Ticket description"),
@@ -1877,20 +1860,20 @@ const createTicketTools = (ctx) => {
1877
1860
  "high",
1878
1861
  "normal",
1879
1862
  "low"
1880
- ]).optional(),
1863
+ ]).optional().describe("Ticket priority. One of urgent, high, normal, low."),
1881
1864
  type: z.enum([
1882
1865
  "problem",
1883
1866
  "incident",
1884
1867
  "question",
1885
1868
  "task"
1886
- ]).optional(),
1887
- assignee_id: z.number().int().optional(),
1888
- group_id: z.number().int().optional(),
1889
- tags: z.array(z.string()).optional(),
1869
+ ]).optional().describe("Ticket type. One of problem, incident, question, task."),
1870
+ assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
1871
+ group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
1872
+ tags: z.array(z.string()).optional().describe("Tags to set on the ticket."),
1890
1873
  custom_fields: z.array(z.object({
1891
1874
  id: z.number().int(),
1892
1875
  value: z.unknown()
1893
- })).optional()
1876
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
1894
1877
  }),
1895
1878
  annotations: {
1896
1879
  readOnlyHint: false,
@@ -1916,7 +1899,7 @@ const createTicketTools = (ctx) => {
1916
1899
  namespace: "tickets",
1917
1900
  readOnly: false,
1918
1901
  title: "Update Zendesk Ticket",
1919
- description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields).",
1902
+ description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields). Only the fields you pass are changed, and the updated ticket is returned. Setting tags here replaces the whole tag set — use manage_tags to add or remove individual tags without overwriting the rest. This tool does not post replies: use add_public_comment or add_private_note for that. Find the ticket id via search_tickets or list_tickets.",
1920
1903
  inputSchema: z.object({
1921
1904
  ticket_id: z.number().int().describe("Ticket ID"),
1922
1905
  status: z.enum([
@@ -1926,27 +1909,27 @@ const createTicketTools = (ctx) => {
1926
1909
  "hold",
1927
1910
  "solved",
1928
1911
  "closed"
1929
- ]).optional(),
1912
+ ]).optional().describe("New ticket status. One of new, open, pending, hold, solved, closed."),
1930
1913
  priority: z.enum([
1931
1914
  "urgent",
1932
1915
  "high",
1933
1916
  "normal",
1934
1917
  "low"
1935
- ]).optional(),
1918
+ ]).optional().describe("Ticket priority. One of urgent, high, normal, low."),
1936
1919
  type: z.enum([
1937
1920
  "problem",
1938
1921
  "incident",
1939
1922
  "question",
1940
1923
  "task"
1941
- ]).optional(),
1942
- assignee_id: z.number().int().optional(),
1943
- group_id: z.number().int().optional(),
1944
- subject: z.string().optional(),
1945
- tags: z.array(z.string()).optional(),
1924
+ ]).optional().describe("Ticket type. One of problem, incident, question, task."),
1925
+ assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
1926
+ group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
1927
+ subject: z.string().optional().describe("New ticket subject line."),
1928
+ tags: z.array(z.string()).optional().describe("Replaces the full tag set on the ticket. Use manage_tags for incremental add/remove."),
1946
1929
  custom_fields: z.array(z.object({
1947
1930
  id: z.number().int(),
1948
1931
  value: z.unknown()
1949
- })).optional()
1932
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
1950
1933
  }),
1951
1934
  annotations: {
1952
1935
  readOnlyHint: false,
@@ -2207,10 +2190,10 @@ const createUserTools = (ctx) => {
2207
2190
  namespace: "users",
2208
2191
  readOnly: true,
2209
2192
  title: "List Zendesk Organizations",
2210
- description: "List all organizations with pagination.",
2193
+ description: "List all organizations with pagination. Returns the name and id of each organization plus basic fields; results are cursor-paginated. Use get_organization with an id for full details (tags, domains, notes), or search for query-based lookups by name. Organizations group end users and can be referenced when creating or filtering tickets.",
2211
2194
  inputSchema: z.object({
2212
- page_size: z.number().int().min(1).max(100).default(100),
2213
- cursor: z.string().optional()
2195
+ page_size: z.number().int().min(1).max(100).default(100).describe("Organizations per page (1-100, default 100)."),
2196
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
2214
2197
  }),
2215
2198
  annotations: {
2216
2199
  readOnlyHint: true,
@@ -2242,8 +2225,9 @@ const createAllTools = (ctx) => [
2242
2225
  /**
2243
2226
  * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
2244
2227
  * token (401). This lets the OAuth store drop the dead token so the next call
2245
- * refreshes/re-authenticates instead of replaying a revoked token. A no-op
2246
- * callback (API-token mode) leaves behavior unchanged.
2228
+ * refreshes/re-authenticates instead of replaying a revoked token. The callback
2229
+ * is omitted only where there is nothing to invalidate (e.g. HTTP per-session
2230
+ * bearer, owned by the client).
2247
2231
  */
2248
2232
  const runHandler = async (def, params, onUnauthorized) => {
2249
2233
  try {
@@ -2704,10 +2688,6 @@ const startStdioTransport = async (server, logger = silentLogger) => {
2704
2688
  //#endregion
2705
2689
  //#region src/index.ts
2706
2690
  const buildStdioServer = (config, logger) => {
2707
- if (config.zendeskEmail && config.zendeskApiToken) {
2708
- const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
2709
- return createMcpServer(config, () => staticToken, logger);
2710
- }
2711
2691
  const tokenStore = createTokenStore({
2712
2692
  subdomain: config.subdomain,
2713
2693
  oauthClientId: config.oauthClientId,