@fruggr/zendesk-mcp-server 2.6.0 → 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 +51 -349
- package/dist/index.js +38 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
124
|
-
|
|
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 an attachment to an article | 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
|
-
|
|
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
|
|
327
|
-
>
|
|
328
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
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) => ({
|
|
@@ -1496,7 +1504,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1496
1504
|
namespace: "help_center",
|
|
1497
1505
|
readOnly: false,
|
|
1498
1506
|
title: "Create Help Center Article",
|
|
1499
|
-
description: "Create a new article in a section. The locale becomes the article's source_locale. Requires a permission_group_id (use list_permission_groups to find available IDs). To add content in other locales afterwards, use create_article_translation.",
|
|
1507
|
+
description: "Create a new article in a section and return the created article with its id. The locale becomes the article's source_locale. Requires a permission_group_id (use list_permission_groups to find available IDs). To add content in other locales afterwards, use create_article_translation.",
|
|
1500
1508
|
inputSchema: z.object({
|
|
1501
1509
|
section_id: z.number().int().describe("Section that will contain the article (numeric id from list_sections)."),
|
|
1502
1510
|
title: z.string().min(1).describe("Title of the new article, in its source locale."),
|
|
@@ -1530,7 +1538,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1530
1538
|
namespace: "help_center",
|
|
1531
1539
|
readOnly: false,
|
|
1532
1540
|
title: "Update Help Center Article",
|
|
1533
|
-
description: "Update article metadata only (draft, promoted, labels, tags, visibility, section, sort position, etc.). Does NOT update content (title, body) — use update_article_translation for that.",
|
|
1541
|
+
description: "Update article metadata only (draft, promoted, labels, tags, visibility, section, sort position, etc.) and return the updated article. Does NOT update content (title, body) — use update_article_translation for that.",
|
|
1534
1542
|
inputSchema: z.object({
|
|
1535
1543
|
article_id: z.number().int().describe("Article ID — the numeric id of the article to update. Obtain it from list_articles or search_articles."),
|
|
1536
1544
|
draft: z.boolean().optional().describe("Set true to unpublish the article (revert to draft) or false to publish it."),
|
|
@@ -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
|
|
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(
|
|
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,
|
|
@@ -1834,12 +1858,12 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1834
1858
|
namespace: "help_center",
|
|
1835
1859
|
readOnly: false,
|
|
1836
1860
|
title: "Create Article Attachment",
|
|
1837
|
-
description: "Upload
|
|
1861
|
+
description: "Upload a file to a Help Center article and return the created attachment (its id, file name, content type, size and content URL). Not idempotent: calling it again uploads another copy rather than replacing the previous one. This is for article assets — for files on support tickets use get_ticket_attachments, and to see an article's existing attachments use list_article_attachments.",
|
|
1838
1862
|
inputSchema: z.object({
|
|
1839
1863
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1840
|
-
file_name: z.string().min(1).describe("
|
|
1841
|
-
file_base64: z.string().min(1).describe("
|
|
1842
|
-
content_type: z.string().default("application/octet-stream").describe("MIME type
|
|
1864
|
+
file_name: z.string().min(1).describe("Name to store the file under, including its extension (e.g. \"screenshot.png\"); used as the download name."),
|
|
1865
|
+
file_base64: z.string().min(1).describe("The file's raw bytes as a base64-encoded string; the server decodes them before upload."),
|
|
1866
|
+
content_type: z.string().default("application/octet-stream").describe("MIME type of the file, e.g. \"image/png\" or \"application/pdf\". Defaults to application/octet-stream when omitted.")
|
|
1843
1867
|
}),
|
|
1844
1868
|
annotations: {
|
|
1845
1869
|
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 >
|
|
1959
|
-
else if (embeddedCount >=
|
|
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",
|