@fruggr/zendesk-mcp-server 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,9 @@ the right tools on your behalf:
33
33
  priority or assignee, or mark it solved.
34
34
  - **Summarize a ticket for reporting or a quick decision** — pull the details and
35
35
  the full comment thread and get the gist in a sentence.
36
+ - **Read the screenshots and photos attached to a ticket** — error dialogs, UI
37
+ captures or product photos are handed to your assistant's own model as images,
38
+ so it can describe them or act on what they show.
36
39
  - **Search and triage your queue in plain language** — "show me my open tickets
37
40
  about billing from this week."
38
41
  - **Draft and maintain knowledge-base articles** — write a new article, or revise
@@ -53,6 +56,7 @@ differently:
53
56
  - **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.
54
57
  - **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.
55
58
  - **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 assistant. Reduces tokens by 10–100× on targeted edits.
59
+ - **Native multimodal attachments** — ticket images are delivered as native MCP image content for the **client's own model** to analyze: no server-side vision model, **no extra API key**, fully vendor-neutral (see [Attachments & vision](#attachments--vision)).
56
60
  - **Read-only mode** — Restrict the server to read operations only, ideal for assistants that should never modify data.
57
61
  - **Lean stack** — Built on the official `@modelcontextprotocol/sdk` plus `zod`.
58
62
 
@@ -60,6 +64,25 @@ Under the hood it speaks to the **Zendesk Support & Help Center (Guide) APIs**,
60
64
  runs locally over **stdio** or as a private **remote MCP server** over HTTP, and
61
65
  ships fine-grained tool-visibility controls — the specifics are below.
62
66
 
67
+ ### Attachments & vision
68
+
69
+ Ticket image attachments are returned as **native MCP multimodal content**, so the
70
+ assistant's own model (Claude, GPT, Gemini, …) sees the pixels directly. There is
71
+ no server-side vision model and **no extra API key** — the opposite of servers
72
+ that expose an `analyze_ticket_images`-style tool calling a vision model with the
73
+ operator's own key.
74
+
75
+ | | This server | Server-side-analysis alternatives |
76
+ |---|---|---|
77
+ | Who sees the image | The **client's own model** receives the pixels as multimodal input | The **server** calls a vision model with **its own API key** |
78
+ | Extra dependencies | None | Vision-model API key + billing on the server |
79
+ | Vendor neutrality | ✅ Full | ❌ Locked to one provider |
80
+
81
+ Non-image attachments come back as text references. The per-image size cap and the
82
+ number of embedded images are **configurable** — see
83
+ [`ZENDESK_MAX_ATTACHMENT_BYTES`](docs/configuration.md#zendesk_max_attachment_bytes)
84
+ and [`ZENDESK_MAX_EMBEDDED_IMAGES`](docs/configuration.md#zendesk_max_embedded_images).
85
+
63
86
  ## When to use this server
64
87
 
65
88
  **Reach for it when:**
@@ -120,75 +143,13 @@ zendesk-mcp-server acme --namespace tickets
120
143
 
121
144
  ## Available tools
122
145
 
123
- <details>
124
- <summary><strong>Tickets</strong></summary>
125
-
126
- | Tool | Description | Mode |
127
- |------|-------------|------|
128
- | `get_ticket` | Retrieve a ticket by ID with optional comments and its live SLA state (resolved via a scoped search) | read |
129
- | `get_ticket_attachments` | Download ticket attachments (images as base64, others as references) | read |
130
- | `search_tickets` | Search tickets using Zendesk query syntax, with per-result SLA state | read |
131
- | `list_tickets` | List tickets with cursor-based pagination | read |
132
- | `get_linked_incidents` | Get incidents linked to a problem ticket | read |
133
- | `list_sla_policies` | List SLA policies with filter conditions and per-priority targets (requires an admin token, or a custom role with the SLA-management permission) | read |
134
- | `create_ticket` | Create a new ticket with subject, description, priority, tags... | write |
135
- | `update_ticket` | Update ticket status, priority, assignee, tags, custom fields | write |
136
- | `add_private_note` | Add an internal note (not visible to requester), optionally with file attachments | write |
137
- | `add_public_comment` | Add a public comment (visible to requester), optionally with file attachments | write |
138
- | `manage_tags` | Add or remove tags on a ticket | write |
139
-
140
- </details>
141
-
142
- <details>
143
- <summary><strong>Help Center</strong></summary>
144
-
145
- | Tool | Description | Mode |
146
- |------|-------------|------|
147
- | `search_articles` | Full-text search across Help Center articles | read |
148
- | `get_article` | Retrieve article by ID with full HTML body | read |
149
- | `get_article_outline` | Compact outline of an article (sections + available translations) | read |
150
- | `get_article_section` | Retrieve a single section (html or markdown) | read |
151
- | `list_categories` | List all Help Center categories | read |
152
- | `list_sections` | List sections, optionally filtered by category | read |
153
- | `list_articles` | List articles with sorting and translation info | read |
154
- | `list_article_translations` | List available translations for an article | read |
155
- | `list_article_attachments` | List attachments on an article | read |
156
- | `list_permission_groups` | List Guide permission groups (needed to create articles) | read |
157
- | `list_content_tags` | List Guide content tags (end-user visible) | read |
158
- | `list_labels` | List article labels (search ranking, not user-visible) | read |
159
- | `list_user_segments` | List user segments (article visibility) | read |
160
- | `compare_translations` | Section-level diff between two locales of an article | read |
161
- | `create_article` | Create a new article in a section | write |
162
- | `update_article` | Update article metadata (draft, labels, tags, visibility, section, sort position) | write |
163
- | `create_article_translation` | Create a translation for an article | write |
164
- | `update_article_translation` | Update an article's translation (full body) | write |
165
- | `update_article_section` | Replace a single section of an article | write |
166
- | `create_content_tag` | Create a new Guide content tag | write |
167
- | `create_article_attachment` | Upload a file to a Help Center article (returns the created attachment) | write |
168
-
169
- </details>
170
-
171
- <details>
172
- <summary><strong>Users & Organizations</strong></summary>
173
-
174
- | Tool | Description | Mode |
175
- |------|-------------|------|
176
- | `get_current_user` | Get the authenticated user (verify identity) | read |
177
- | `search_users` | Search users by name, email, or query syntax | read |
178
- | `get_user` | Retrieve a user by ID | read |
179
- | `get_organization` | Retrieve an organization by ID | read |
180
- | `list_organizations` | List all organizations with pagination | read |
181
-
182
- </details>
183
-
184
- <details>
185
- <summary><strong>Search</strong></summary>
186
-
187
- | Tool | Description | Mode |
188
- |------|-------------|------|
189
- | `search` | Unified search across tickets, users, and organizations | read |
146
+ The tools are grouped into four namespaces — **Tickets**, **Help Center**,
147
+ **Users & Organizations**, and **Search** — each of which you can enable
148
+ selectively with `--namespace` (see [Tool modes](#tool-modes)).
190
149
 
191
- </details>
150
+ The full tool-by-tool reference — every tool with its description and its
151
+ `read`/`write` mode — lives in
152
+ **[docs/mcp-tools-reference.md](docs/mcp-tools-reference.md)**.
192
153
 
193
154
  ## Help Center context (instructions + resources)
194
155
 
@@ -323,298 +284,39 @@ Add to your `.vscode/mcp.json`:
323
284
 
324
285
  ## Quick start: remote (HTTP)
325
286
 
326
- > 🧪 **Experimental.** The HTTP transport is shipped but has not yet been
327
- > exercised end-to-end against a real Zendesk tenant from every supported
328
- > MCP client. Local stdio is the supported path. Until this notice is
329
- > removed, expect rough edges around OAuth discovery behind reverse
330
- > proxies, CORS with browser clients, and 401 / refresh flows — please
331
- > open an issue with the symptoms you hit.
332
-
333
- Deploy a private MCP server for **one** Zendesk account. Every MCP client connecting to the server presents its **own** user's OAuth bearer in `Authorization:` — the server never sees a shared admin key.
334
-
335
- ### Zendesk OAuth setup
336
-
337
- Same procedure as the [local quick start](#zendesk-oauth-setup), with one difference: the **Redirect URL** must match the callback your MCP client uses — provided by the client itself, e.g. `https://claude.ai/oauth/callback` for claude.ai on the web. Check your client's docs.
338
-
339
- ### Run the server
340
-
341
- ```bash
342
- zendesk-mcp-server <your-subdomain> --transport http --port 3000 \
343
- --public-url https://mcp.example.com
344
- # stderr: Zendesk MCP server running via http on 0.0.0.0:3000
345
- ```
346
-
347
- ### Public URL
348
-
349
- `--public-url` (or `PUBLIC_URL=…`) is the URL **clients use to reach you**. It's what gets advertised in the OAuth discovery metadata as the canonical resource identifier (RFC 8707). When the server is behind a TLS reverse proxy — Azure App Service, Heroku, Fly.io, Cloudflare Tunnel, nginx, Caddy… — the bind host and the public URL differ, and spec-compliant MCP clients will refuse the connection if the metadata advertises the wrong resource. Without it the server boots in a degraded mode and prints a warning.
350
-
351
- | Platform | Recommended setup |
352
- |---|---|
353
- | **Azure App Service** | Startup command: `PUBLIC_URL="https://$WEBSITE_HOSTNAME" zendesk-mcp-server $ZENDESK_SUBDOMAIN --transport http --port $PORT` |
354
- | **Heroku / Fly / Cloud Run** | `PUBLIC_URL=https://<your-app>.<provider>.app` in the env / config |
355
- | **Caddy / nginx / Traefik in front of a VM** | `PUBLIC_URL=https://mcp.example.com` |
356
- | **Local dev (no proxy)** | `--host 127.0.0.1 --port 3000` — the resource URL is derived automatically (the wildcard `0.0.0.0` is what triggers the warning) |
357
-
358
- ### Authentication on every request
359
-
360
- `Authorization: Bearer …` is required on **every** `/mcp` request — a session id alone is never accepted as a credential. The most recent bearer presented on a session is the one used for Zendesk calls, so a client refreshing its token mid-session just works.
361
-
362
- ### Verify discovery endpoints
363
-
364
- Served by the HTTP transport in `src/transports/http.ts`:
365
-
366
- ```bash
367
- curl -s http://localhost:3000/.well-known/oauth-protected-resource
368
- # → { "authorization_servers": ["https://<subdomain>.zendesk.com"], ... }
369
-
370
- curl -s http://localhost:3000/.well-known/oauth-authorization-server
371
- # → { "issuer": "https://<subdomain>.zendesk.com", "authorization_endpoint": "...", ... }
372
-
373
- curl -s -i http://localhost:3000/healthz # → 200 OK
374
- ```
375
-
376
- ### MCP client wiring
377
-
378
- Every major MCP client supports remote servers over Streamable HTTP and handles the OAuth 2.1 PKCE discovery flow natively — paste the URL, sign in once, you're connected. Replace `https://mcp.example.com` below with your deployed origin.
379
-
380
- <details>
381
- <summary><strong>Claude Code (CLI)</strong></summary>
382
-
383
- ```bash
384
- claude mcp add zendesk --transport http https://mcp.example.com/mcp
385
- ```
386
-
387
- </details>
388
-
389
- <details>
390
- <summary><strong>Claude Desktop</strong></summary>
391
-
392
- **Settings → Connectors → + Add custom connector**, paste `https://mcp.example.com/mcp`, click **Connect**. Claude Desktop drives the OAuth flow in your browser on first call.
393
-
394
- </details>
395
-
396
- <details>
397
- <summary><strong>claude.ai (web)</strong></summary>
398
-
399
- **Settings → Connectors → Add custom connector**, same URL. The OAuth flow runs in the same tab.
400
-
401
- </details>
402
-
403
- <details>
404
- <summary><strong>VS Code (GitHub Copilot / Continue / Cline)</strong></summary>
405
-
406
- Add to your `.vscode/mcp.json`:
407
-
408
- ```json
409
- {
410
- "servers": {
411
- "zendesk": {
412
- "type": "http",
413
- "url": "https://mcp.example.com/mcp"
414
- }
415
- }
416
- }
417
- ```
418
-
419
- </details>
420
-
421
- <details>
422
- <summary><strong>Cursor, Windsurf</strong></summary>
423
-
424
- Both expose an MCP settings UI that accepts a remote URL. Paste `https://mcp.example.com/mcp` and sign in when prompted.
425
-
426
- </details>
427
-
428
- <details>
429
- <summary><strong>Zed</strong></summary>
430
-
431
- Zed added native OAuth 2.0 + PKCE for Streamable HTTP MCP servers in 2026 ([zed-industries/zed#51768](https://github.com/zed-industries/zed/pull/51768)). Configure the remote server in your Zed settings; on first use Zed opens a loopback browser callback to complete the flow.
432
-
433
- If you're on an older Zed build that predates that change, fall back to [`mcp-remote`](https://github.com/geelen/mcp-remote) as a local shim that does the OAuth flow on your machine and proxies the session:
434
-
435
- ```json
436
- {
437
- "context_servers": {
438
- "zendesk": {
439
- "command": "npx",
440
- "args": ["-y", "mcp-remote", "https://mcp.example.com/mcp"]
441
- }
442
- }
443
- }
444
- ```
445
-
446
- </details>
447
-
448
- On the first call the MCP client fetches the discovery metadata, performs the OAuth 2.1 PKCE flow against Zendesk on behalf of the **end user**, and sends the resulting access token as a `Bearer` to the server. Each subsequent tool call runs with that user's Zendesk permissions.
449
-
450
- ### CORS
451
-
452
- The HTTP transport ships a default CORS allowlist that covers today's major **browser-based** MCP clients out of the box (ordered by user base): `chatgpt.com`, `claude.ai`, `gemini.google.com`, `copilot.microsoft.com`, `perplexity.ai`, `chat.mistral.ai`, `grok.com`, plus `chat.openai.com`. Localhost on any port (MCP Inspector, dev pages) is also always allowed.
453
-
454
- **Native MCP clients** (Claude Desktop / Claude Code CLI / Cursor / VS Code / Zed) send no `Origin` header — CORS doesn't apply to them, they work regardless.
455
-
456
- To allow an additional browser origin (custom dashboard, internal portal), pass `--cors-origin` (repeatable) or set `CORS_ORIGIN` as a comma-separated list:
457
-
458
- ```bash
459
- zendesk-mcp-server acme --transport http --port 3000 \
460
- --cors-origin https://internal-dashboard.example.com \
461
- --cors-origin https://team-portal.example.com
462
- ```
463
-
464
- The defaults are always applied — your additions extend them, they don't replace them.
465
-
466
- ### Operator responsibilities
287
+ > 🧪 **Experimental.** The HTTP transport is shipped but not yet exercised
288
+ > end-to-end against a real Zendesk tenant from every MCP client — local stdio is
289
+ > the supported path.
467
290
 
468
- This server provides the MCP transport and the OAuth discovery metadata. The operator is still responsible for:
291
+ You can also deploy a private remote MCP server for **one** Zendesk account, where
292
+ every MCP client presents its **own** user's OAuth bearer in `Authorization:` (the
293
+ server never sees a shared admin key). The full guide — OAuth setup, `--public-url`
294
+ behind a reverse proxy, per-platform config, discovery endpoints, MCP client
295
+ wiring, CORS and operator responsibilities — is in
296
+ **[docs/http-deployment.md](docs/http-deployment.md)**.
469
297
 
470
- - **TLS termination** (put the server behind a reverse proxy like Caddy / nginx / Cloudflare Tunnel)
471
- - **Network exposure & firewall** (the server binds `0.0.0.0` by default — choose carefully)
472
- - **Process supervision** (systemd, Docker, fly.io, your hosting provider's runner — none is shipped here)
298
+ ## Configuration
473
299
 
474
- ## CLI reference
475
-
476
- ```
477
- zendesk-mcp-server <subdomain> [options]
478
-
479
- Options:
480
- --mode <mode> single | namespace (default) | all
481
- --namespace <ns> Filter by namespace (repeatable): tickets, help_center, users
482
- --tool <name> Filter by tool name (repeatable, forces --mode all)
483
- --read-only Only expose read operations
484
- --no-topology Disable the Help Center structural context
485
- (instructions + zendesk-hc://topology resource)
486
- --log-level <level> debug | info (default) | warn | error
487
- --transport <t> stdio (default) | http
488
- --host <host> HTTP bind host (default: 0.0.0.0)
489
- --port <port> HTTP bind port (default: 3000; 0 = OS-assigned)
490
- --public-url <url> Public URL clients use to reach the server (HTTP mode,
491
- required behind a TLS reverse proxy)
492
- --cors-origin <url> Extra browser origin allowed by CORS (repeatable;
493
- adds to the default allowlist of major web MCP
494
- clients + localhost-any-port)
495
- --callback-port <port> Local OAuth callback port for stdio (default 27439)
496
- ```
497
-
498
- `--namespace` and `--read-only` are applied before the proxies are registered, so they narrow the surface in every mode — in the default `namespace` mode, `--namespace help_center` registers a single proxy (`zendesk_help_center`) instead of three.
499
-
500
- **Examples:**
501
-
502
- ```bash
503
- # Local single-tool mode — minimal context, every operation in one tool
504
- zendesk-mcp-server acme --mode single
505
-
506
- # Read-only tickets only
507
- zendesk-mcp-server acme --read-only --namespace tickets
508
-
509
- # Cherry-pick specific tools
510
- zendesk-mcp-server acme --tool get_ticket --tool search_tickets --tool get_current_user
511
-
512
- # Remote HTTP, read-only Help Center surface
513
- zendesk-mcp-server acme --transport http --port 8080 \
514
- --namespace help_center --read-only
515
- ```
516
-
517
- ## Environment variables
518
-
519
- | Variable | Required | Default | Description |
520
- |----------|----------|---------|-------------|
521
- | `ZENDESK_SUBDOMAIN` | yes (or CLI arg) | — | Zendesk subdomain (e.g., `acme` for acme.zendesk.com) |
522
- | `ZENDESK_OAUTH_CLIENT_ID` | no | `<subdomain>_zendesk` | OAuth client identifier |
523
- | `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**. |
524
- | `ZENDESK_TOKEN_FILE` | no | OS config dir | Path to the persisted OAuth token file (`0600`). |
525
- | `TRANSPORT` | no | `stdio` | `stdio` or `http` |
526
- | `HOST` | no | `0.0.0.0` | HTTP bind host |
527
- | `PORT` | no | `3000` | HTTP bind port (`0` to let the OS pick) |
528
- | `PUBLIC_URL` | recommended in HTTP behind a proxy | derived from host:port | Public URL advertised in OAuth discovery metadata |
529
- | `CORS_ORIGIN` | no | — | Comma-separated browser origins added to the default CORS allowlist |
530
- | `LOG_LEVEL` | no | `info` | Log verbosity (`debug` surfaces the full OAuth flow trace) |
300
+ The complete reference for the CLI flags (`--mode`, `--namespace`, `--read-only`,
301
+ `--transport`, `--public-url`, …) and the environment variables (`ZENDESK_SUBDOMAIN`,
302
+ `ZENDESK_TOKEN_FILE`, `PUBLIC_URL`, the attachment-vision caps, …) lives in
303
+ **[docs/configuration.md](docs/configuration.md)** — with a per-variable anchor so
304
+ you can deep-link a specific setting.
531
305
 
532
306
  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).
533
307
 
534
308
  ## Troubleshooting
535
309
 
536
- ### The browser doesn't open during OAuth login
537
-
538
- The OAuth flow opens your default browser on the first tool call. The first call
539
- fails fast with a message that includes the authorize URL, so even if the
540
- browser can't open (common in sandboxed or remote desktop environments) you can
541
- open that URL manually — it's also printed to the server's stderr. Sign in, then
542
- retry the request.
543
-
544
- To collect diagnostics, restart with `LOG_LEVEL=debug`. The server then emits
545
- structured logs through **two channels**, so they're reachable on any MCP client:
546
-
547
- - **stderr** — captured to a log file by every mainstream client.
548
- - **MCP logging notifications** (`notifications/message`) — surfaced by clients
549
- that support the `logging` capability.
550
-
551
- When the browser fails to open, look for the `oauth_browser_open_failed` event:
552
- it reports the underlying error, the platform, and which environment markers are
553
- present (no secrets, tokens, or env values are ever logged).
554
-
555
- ### The OAuth callback port is already in use
556
-
557
- The sign-in flow runs a short-lived local server on port `27439` to receive the
558
- callback. If that port is taken, the first tool call fails with a message saying
559
- so (and logs `oauth_callback_listen_failed`). Pick a free port with
560
- `ZENDESK_OAUTH_CALLBACK_PORT=<port>` (or `--callback-port <port>`), and register
561
- the matching `http://localhost:<port>/callback` redirect URL in your Zendesk
562
- OAuth client.
563
-
564
- ### I have to re-authenticate every time
565
-
566
- The OAuth token is persisted to an owner-only file in your OS config dir and
567
- reused across restarts, so this shouldn't happen. If it does, check that the file
568
- is writable (`ZENDESK_TOKEN_FILE` to relocate it) and look for
569
- `token_persist_failed` in the logs.
570
-
571
- Where each client writes the server's stderr:
572
-
573
- | Client | Log location |
574
- |--------|--------------|
575
- | Claude Desktop (macOS) | `~/Library/Logs/Claude/mcp-server-*.log` |
576
- | Claude Desktop (Windows) | `%APPDATA%\Claude\logs\mcp-server-*.log` |
577
- | Claude Code | `claude --debug`, or the session logs |
578
- | Cursor / VS Code / Cline | the extension's MCP output/log panel |
310
+ Browser not opening during OAuth login, the callback port already in use, having
311
+ to re-authenticate every time, and where each client writes the server's stderr
312
+ are covered in **[docs/troubleshooting.md](docs/troubleshooting.md)**. Restart
313
+ with `LOG_LEVEL=debug` for the full OAuth flow trace.
579
314
 
580
315
  ## Development
581
316
 
582
- ### Toolchain
583
-
584
- | Tool | Version | Source of truth |
585
- | ---- | ------- | ---------------- |
586
- | Node | 24 | [`.nvmrc`](.nvmrc) — read by `nvm`, `fnm`, `mise`, `asdf`, `volta` |
587
- | pnpm | 11 | [`package.json#packageManager`](package.json) (pinned with a corepack integrity hash) |
588
-
589
- The toolchain (Node 24 + pnpm 11) is used to build, lint, type-check and
590
- test the project. The **published package** still runs on Node 20+ (see
591
- `engines.node`); a dedicated CI job installs the packed tarball on Node 20
592
- and runs the smoke test to keep that promise honest.
593
-
594
- ```bash
595
- # Clone, install, build
596
- git clone https://github.com/fruggr/zendesk-mcp-server.git
597
- cd zendesk-mcp-server && pnpm install && pnpm build
598
- node dist/index.js <your-subdomain>
599
-
600
- # Dev mode, OAuth (browser opens on first tool call)
601
- pnpm dev -- <your-subdomain> --mode all
602
-
603
- # Dev mode, HTTP transport (OAuth bearer from the MCP client)
604
- pnpm dev -- <your-subdomain> --transport http --port 3000 --public-url http://localhost:3000
605
-
606
- # Build / typecheck / lint / test
607
- pnpm build && pnpm typecheck && pnpm check && pnpm test
608
- ```
609
-
610
- To test a PR branch without publishing to npm — the `prepare` script builds on install:
611
-
612
- ```bash
613
- npx -y github:fruggr/zendesk-mcp-server <your-subdomain>
614
- npx -y github:fruggr/zendesk-mcp-server#my-feature-branch <your-subdomain>
615
- ```
616
-
617
- Contributor conventions (architecture, code style, submission bar, release workflow) live in [`AGENTS.md`](AGENTS.md).
317
+ Setting up the repo, the toolchain (Node 24 + pnpm 11), dev mode and how to test
318
+ a PR branch are covered in **[CONTRIBUTING.md](CONTRIBUTING.md#development-setup)**.
319
+ Architecture and code-style conventions live in [`AGENTS.md`](AGENTS.md).
618
320
 
619
321
  ## Inspiration & related projects
620
322
 
package/dist/index.js CHANGED
@@ -108,7 +108,15 @@ const createLogger = (level) => {
108
108
  //#endregion
109
109
  //#region src/constants.ts
110
110
  const CHARACTER_LIMIT = 25e3;
111
- const MAX_COMMENT_PAGES = Number(process.env["ZENDESK_MAX_COMMENT_PAGES"] ?? 10);
111
+ const positiveIntEnv = (name, fallback) => {
112
+ const raw = process.env[name];
113
+ if (raw === void 0 || raw.trim() === "") return fallback;
114
+ const parsed = Number(raw);
115
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
116
+ };
117
+ const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
118
+ const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
119
+ const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
112
120
  const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
113
121
  const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
114
122
  const getOAuthUrls = (subdomain) => ({
@@ -1563,18 +1571,34 @@ const createHelpCenterTools = (ctx) => {
1563
1571
  namespace: "help_center",
1564
1572
  readOnly: true,
1565
1573
  title: "List Content Tags",
1566
- description: "List all Guide content tags. Content tags are visible to end users and help them find related articles.",
1567
- inputSchema: z.object({}),
1574
+ description: "List Guide content tags, which are end-user-visible labels that help readers find related articles. Results are cursor-paginated (follow the returned cursor to enumerate the full list) and sorted by name by default. Pass name_prefix to look a tag up by the start of its name — do this before create_content_tag to reuse an existing tag rather than fragment the taxonomy. For internal, non-end-user search labels, see list_labels instead.",
1575
+ inputSchema: z.object({
1576
+ name_prefix: z.string().min(1).optional().describe("Return only content tags whose name starts with this prefix (prefix match — not a substring or fuzzy search). Use the full name to check whether a specific tag already exists before creating it."),
1577
+ sort_by: z.enum(["name", "id"]).default("name").describe("Field to sort by; \"name\" (the default) lists tags alphabetically."),
1578
+ sort_order: z.enum(["asc", "desc"]).default("asc").describe("Sort direction: ascending or descending."),
1579
+ page_size: z.number().int().min(1).max(100).default(100).describe("Content tags per page (1-100, default 100)."),
1580
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
1581
+ }),
1568
1582
  annotations: {
1569
1583
  readOnlyHint: true,
1570
1584
  destructiveHint: false,
1571
1585
  idempotentHint: true,
1572
1586
  openWorldHint: true
1573
1587
  },
1574
- handler: async () => {
1588
+ handler: async (params) => {
1589
+ const { name_prefix, sort_by, sort_order, page_size, cursor } = params;
1590
+ const token = await getToken();
1591
+ const sort = `${sort_order === "desc" ? "-" : ""}${sort_by}`;
1592
+ const p = {
1593
+ ...buildCursorParams(page_size, cursor),
1594
+ sort
1595
+ };
1596
+ if (name_prefix) p["filter[name_prefix]"] = name_prefix;
1597
+ const response = await zendeskGet(subdomain, token, "/guide/content_tags", p);
1598
+ const records = response.records ?? [];
1575
1599
  return { content: [{
1576
1600
  type: "text",
1577
- text: formatList((await zendeskGet(subdomain, await getToken(), "/guide/content_tags")).records ?? [], formatContentTag)
1601
+ text: formatList(records, formatContentTag, extractPaginationMeta(response, records.length))
1578
1602
  }] };
1579
1603
  }
1580
1604
  },
@@ -1583,7 +1607,7 @@ const createHelpCenterTools = (ctx) => {
1583
1607
  namespace: "help_center",
1584
1608
  readOnly: false,
1585
1609
  title: "Create Content Tag",
1586
- 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.",
1610
+ 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 (filter by name_prefix) 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.",
1587
1611
  inputSchema: z.object({ name: z.string().min(1).describe("Content tag name as shown to end users (e.g., \"billing\", \"getting-started\").") }),
1588
1612
  annotations: {
1589
1613
  readOnlyHint: false,
@@ -1955,8 +1979,8 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
1955
1979
  continue;
1956
1980
  }
1957
1981
  let skipReason = null;
1958
- if (attachment.size > 5242880) skipReason = "skipped: exceeds 5 MB per-image limit";
1959
- else if (embeddedCount >= 10) skipReason = `skipped: max 10 embedded images reached`;
1982
+ if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${+(MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2)} MB per-image limit`;
1983
+ else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
1960
1984
  if (skipReason) {
1961
1985
  blocks.push({
1962
1986
  type: "text",