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