@fruggr/zendesk-mcp-server 2.20.2 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -7
- package/dist/index.js +806 -92
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -135,6 +135,7 @@ const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("ZENDESK_MAX_RESPONSE_BYTES",
|
|
|
135
135
|
const MAX_BASE64_INPUT_CHARS = MESSAGE_CONTENT_BUDGET_BYTES;
|
|
136
136
|
const MAX_BASE64_INPUT_MB = Number.parseFloat((MAX_BASE64_INPUT_CHARS / 4 * 3 / 1048576).toFixed(2));
|
|
137
137
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
138
|
+
const TICKET_FIELD_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", 10);
|
|
138
139
|
const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
|
|
139
140
|
const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
|
|
140
141
|
const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
|
|
@@ -143,6 +144,48 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
143
144
|
tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
|
|
144
145
|
});
|
|
145
146
|
//#endregion
|
|
147
|
+
//#region src/auth/oauth-scopes.ts
|
|
148
|
+
/**
|
|
149
|
+
* The OAuth scope the server asks Zendesk for, and the one predicate that
|
|
150
|
+
* decides whether a cached token's grant is still good enough.
|
|
151
|
+
*
|
|
152
|
+
* Both scope strings live here rather than in `constants.ts` on purpose: they
|
|
153
|
+
* are behavioural strings whose mutants must be killed by assertions
|
|
154
|
+
* (`docs/decisions/mutation-testing.md`, "OAuth parameters and scopes"), and
|
|
155
|
+
* `src/auth/**` is inside the mutation scope while `constants.ts` is not.
|
|
156
|
+
* Keeping them next to the predicate that consumes them keeps both under the
|
|
157
|
+
* same gate.
|
|
158
|
+
*/
|
|
159
|
+
const READ_SCOPE = "read";
|
|
160
|
+
const READ_WRITE_SCOPE = "read write";
|
|
161
|
+
const WHITESPACE = /\s+/;
|
|
162
|
+
const scopeTokens = (scope) => scope.split(WHITESPACE).filter((s) => s.length > 0);
|
|
163
|
+
/**
|
|
164
|
+
* The scope to request for a given tool surface. `--read-only` already filters
|
|
165
|
+
* every write tool out of the surface, so asking Zendesk for `write` on top of
|
|
166
|
+
* that would be requesting an authority the server cannot even exercise — and
|
|
167
|
+
* an OAuth client whose allowed scopes stop at `read` rejects the whole
|
|
168
|
+
* authorize request with `invalid_scope`, minting no token at all (#283).
|
|
169
|
+
*/
|
|
170
|
+
const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
|
|
171
|
+
/**
|
|
172
|
+
* The same decision as a list, for the RFC 9728 / RFC 8414 `scopes_supported`
|
|
173
|
+
* metadata. Derived from `requestedScope` so what the HTTP transport advertises
|
|
174
|
+
* provably cannot drift from what the stdio flow requests.
|
|
175
|
+
*/
|
|
176
|
+
const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
|
|
177
|
+
/**
|
|
178
|
+
* Whether a grant still covers what this process needs: a flat subset test.
|
|
179
|
+
* Coverage, not equality, so a broader token stays usable and two servers
|
|
180
|
+
* sharing a token file converge. A non-string `granted` is a pre-#283 record,
|
|
181
|
+
* i.e. `read write`. No scope hierarchy: granular scopes (#284) replace this.
|
|
182
|
+
*/
|
|
183
|
+
const grantCovers = (granted, requested) => {
|
|
184
|
+
if (typeof granted !== "string") return true;
|
|
185
|
+
const held = new Set(scopeTokens(granted));
|
|
186
|
+
return scopeTokens(requested).every((token) => held.has(token));
|
|
187
|
+
};
|
|
188
|
+
//#endregion
|
|
146
189
|
//#region src/auth/browser-oauth.ts
|
|
147
190
|
const AUTH_TIMEOUT_MS = 3e5;
|
|
148
191
|
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
@@ -185,7 +228,7 @@ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier
|
|
|
185
228
|
* open for up to the 5-minute timeout.
|
|
186
229
|
*/
|
|
187
230
|
const startBrowserAuth = (config, logger = silentLogger) => {
|
|
188
|
-
const { subdomain, oauthClientId } = config;
|
|
231
|
+
const { subdomain, oauthClientId, readOnly } = config;
|
|
189
232
|
const { authorizeUrl: authorizeBase, tokenUrl } = getOAuthUrls(subdomain);
|
|
190
233
|
const codeVerifier = generateCodeVerifier();
|
|
191
234
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
@@ -308,7 +351,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
308
351
|
response_type: "code",
|
|
309
352
|
client_id: oauthClientId,
|
|
310
353
|
redirect_uri: redirectUri,
|
|
311
|
-
scope:
|
|
354
|
+
scope: requestedScope(readOnly),
|
|
312
355
|
code_challenge: codeChallenge,
|
|
313
356
|
code_challenge_method: "S256"
|
|
314
357
|
});
|
|
@@ -497,8 +540,18 @@ const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */
|
|
|
497
540
|
const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
|
|
498
541
|
const createTokenStore = (config, logger = silentLogger) => {
|
|
499
542
|
const tokenPath = resolveTokenPath(config.subdomain);
|
|
543
|
+
const requested = requestedScope(config.readOnly);
|
|
500
544
|
let token = loadToken(tokenPath);
|
|
501
|
-
if (token)
|
|
545
|
+
if (token) {
|
|
546
|
+
if (grantCovers(token.scope, requested)) logger.debug("oauth_token_loaded_from_disk");
|
|
547
|
+
else {
|
|
548
|
+
logger.warn("oauth_token_scope_insufficient", {
|
|
549
|
+
requested,
|
|
550
|
+
granted: token.scope
|
|
551
|
+
});
|
|
552
|
+
token = void 0;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
502
555
|
let authorizeUrl;
|
|
503
556
|
let starting;
|
|
504
557
|
let refreshing;
|
|
@@ -507,12 +560,20 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
507
560
|
const setToken = (accessToken, refreshToken) => {
|
|
508
561
|
token = {
|
|
509
562
|
accessToken,
|
|
510
|
-
refreshToken
|
|
563
|
+
refreshToken,
|
|
564
|
+
scope: requested
|
|
511
565
|
};
|
|
512
566
|
probedUnknownExpiry = false;
|
|
513
567
|
persist(token);
|
|
514
568
|
};
|
|
515
569
|
const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
|
|
570
|
+
const noteGrant = (granted) => {
|
|
571
|
+
if (!grantCovers(granted, requested)) logger.warn("oauth_token_grant_narrowed", {
|
|
572
|
+
requested,
|
|
573
|
+
granted
|
|
574
|
+
});
|
|
575
|
+
return granted;
|
|
576
|
+
};
|
|
516
577
|
const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
|
|
517
578
|
if (!current.refreshToken) return void 0;
|
|
518
579
|
try {
|
|
@@ -524,7 +585,8 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
524
585
|
token = {
|
|
525
586
|
accessToken: result.access_token,
|
|
526
587
|
refreshToken: result.refresh_token ?? current.refreshToken,
|
|
527
|
-
expiresAt: expiryFrom(result.expires_in)
|
|
588
|
+
expiresAt: expiryFrom(result.expires_in),
|
|
589
|
+
scope: noteGrant(result.scope || current.scope)
|
|
528
590
|
};
|
|
529
591
|
probedUnknownExpiry = true;
|
|
530
592
|
persist(token);
|
|
@@ -544,14 +606,16 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
544
606
|
return startBrowserAuth({
|
|
545
607
|
subdomain: config.subdomain,
|
|
546
608
|
oauthClientId: config.oauthClientId,
|
|
547
|
-
callbackPort: config.callbackPort
|
|
609
|
+
callbackPort: config.callbackPort,
|
|
610
|
+
readOnly: config.readOnly
|
|
548
611
|
}, logger).then((started) => {
|
|
549
612
|
authorizeUrl = started.authorizeUrl;
|
|
550
613
|
started.tokenPromise.then((result) => {
|
|
551
614
|
token = {
|
|
552
615
|
accessToken: result.access_token,
|
|
553
616
|
refreshToken: result.refresh_token,
|
|
554
|
-
expiresAt: expiryFrom(result.expires_in)
|
|
617
|
+
expiresAt: expiryFrom(result.expires_in),
|
|
618
|
+
scope: noteGrant(result.scope || requested)
|
|
555
619
|
};
|
|
556
620
|
probedUnknownExpiry = true;
|
|
557
621
|
persist(token);
|
|
@@ -593,7 +657,8 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
593
657
|
token = {
|
|
594
658
|
accessToken: token.accessToken,
|
|
595
659
|
refreshToken: token.refreshToken,
|
|
596
|
-
expiresAt: 0
|
|
660
|
+
expiresAt: 0,
|
|
661
|
+
scope: token.scope
|
|
597
662
|
};
|
|
598
663
|
persist(token);
|
|
599
664
|
} else {
|
|
@@ -632,8 +697,24 @@ const LogLevel = z.enum([
|
|
|
632
697
|
const Namespace = z.enum([
|
|
633
698
|
"tickets",
|
|
634
699
|
"help_center",
|
|
635
|
-
"users"
|
|
700
|
+
"users",
|
|
701
|
+
"requests"
|
|
636
702
|
]);
|
|
703
|
+
/**
|
|
704
|
+
* Namespaces exposed when the operator passes no `--namespace` flag.
|
|
705
|
+
*
|
|
706
|
+
* Deliberately NOT every member of the enum: the end-user `requests` surface
|
|
707
|
+
* is opt-in, because under an agent token part of it silently misbehaves
|
|
708
|
+
* rather than failing -- `solved: true` returns 200 and changes nothing, and
|
|
709
|
+
* `required_in_portal` validation is not applied to agents. Registering it for
|
|
710
|
+
* every agent install would ship an operation that reports success on a no-op.
|
|
711
|
+
* Secondarily, an agent's tool list and context budget stay unchanged.
|
|
712
|
+
*/
|
|
713
|
+
const DEFAULT_NAMESPACES = [
|
|
714
|
+
"tickets",
|
|
715
|
+
"help_center",
|
|
716
|
+
"users"
|
|
717
|
+
];
|
|
637
718
|
const Transport = z.enum(["stdio", "http"]);
|
|
638
719
|
const ConfigSchema = z.object({
|
|
639
720
|
subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
|
|
@@ -641,7 +722,18 @@ const ConfigSchema = z.object({
|
|
|
641
722
|
logLevel: LogLevel,
|
|
642
723
|
mode: ToolMode,
|
|
643
724
|
readOnly: z.boolean(),
|
|
644
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Active namespaces, defaulting to DEFAULT_NAMESPACES rather than everything.
|
|
727
|
+
*
|
|
728
|
+
* The default lives HERE, not in `loadConfig`: the integration harness builds
|
|
729
|
+
* its Config through `ConfigSchema.parse` and never calls `loadConfig`, so a
|
|
730
|
+
* default applied there would leave `requests` visible to every scenario and
|
|
731
|
+
* absent in production.
|
|
732
|
+
*
|
|
733
|
+
* `.min(1)` rejects an explicit `[]`, which `filterTools` reads as no filter
|
|
734
|
+
* at all (it guards on `?.length`) and would expose the opt-in namespace.
|
|
735
|
+
*/
|
|
736
|
+
namespaces: z.array(Namespace).min(1).default([...DEFAULT_NAMESPACES]),
|
|
645
737
|
tools: z.array(z.string()).optional(),
|
|
646
738
|
/**
|
|
647
739
|
* Whether to expose the Help Center structural context (the `instructions`
|
|
@@ -692,6 +784,14 @@ const ConfigSchema = z.object({
|
|
|
692
784
|
* the "Dev mode" section of docs/configuration.md.
|
|
693
785
|
*/
|
|
694
786
|
dev: z.boolean().default(false),
|
|
787
|
+
/**
|
|
788
|
+
* Print the tool surface the current flags resolve to, then exit without
|
|
789
|
+
* starting a server or touching the network. `--print-tools` exists because
|
|
790
|
+
* the surface is shaped by three independent knobs (`--namespace` / `--tool`
|
|
791
|
+
* pick the inventory, `--mode` packages it, `--read-only` narrows it) and
|
|
792
|
+
* their combination is easier to read off a listing than to predict.
|
|
793
|
+
*/
|
|
794
|
+
printTools: z.boolean().default(false),
|
|
695
795
|
transport: Transport,
|
|
696
796
|
host: z.string().min(1),
|
|
697
797
|
port: z.number().int().min(0).max(65535),
|
|
@@ -741,7 +841,8 @@ const CLI_OPTIONS = {
|
|
|
741
841
|
"read-only": { type: "boolean" },
|
|
742
842
|
"no-topology": { type: "boolean" },
|
|
743
843
|
"no-promoted-articles": { type: "boolean" },
|
|
744
|
-
dev: { type: "boolean" }
|
|
844
|
+
dev: { type: "boolean" },
|
|
845
|
+
"print-tools": { type: "boolean" }
|
|
745
846
|
};
|
|
746
847
|
new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
|
|
747
848
|
const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
|
|
@@ -759,7 +860,8 @@ const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
|
|
|
759
860
|
["read-only", { readOnly: true }],
|
|
760
861
|
["no-topology", { topology: false }],
|
|
761
862
|
["no-promoted-articles", { promotedArticles: false }],
|
|
762
|
-
["dev", { dev: true }]
|
|
863
|
+
["dev", { dev: true }],
|
|
864
|
+
["print-tools", { printTools: true }]
|
|
763
865
|
]);
|
|
764
866
|
const parseCliArgs = (args) => {
|
|
765
867
|
const { values, positionals } = parseArgs({
|
|
@@ -798,6 +900,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
798
900
|
const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
|
|
799
901
|
const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
|
|
800
902
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
903
|
+
const namespaces = cli.namespaces ?? (cli.tools?.length ? [...Namespace.options] : void 0);
|
|
801
904
|
const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
802
905
|
const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
|
|
803
906
|
return ConfigSchema.parse({
|
|
@@ -806,12 +909,13 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
806
909
|
logLevel: cli.logLevel ?? requireNonEmptyEnv("LOG_LEVEL") ?? "info",
|
|
807
910
|
mode,
|
|
808
911
|
readOnly: cli.readOnly ?? false,
|
|
809
|
-
namespaces
|
|
912
|
+
namespaces,
|
|
810
913
|
tools: cli.tools,
|
|
811
914
|
topology: cli.topology ?? true,
|
|
812
915
|
promotedArticles: cli.promotedArticles ?? true,
|
|
813
916
|
hcResourceScheme,
|
|
814
917
|
dev: cli.dev ?? false,
|
|
918
|
+
printTools: cli.printTools ?? false,
|
|
815
919
|
callbackPort,
|
|
816
920
|
...resolveTransportSettings(cli)
|
|
817
921
|
});
|
|
@@ -1323,6 +1427,25 @@ const formatComment = (comment, authors) => {
|
|
|
1323
1427
|
lines.push("", comment.body);
|
|
1324
1428
|
return lines.join("\n");
|
|
1325
1429
|
};
|
|
1430
|
+
const formatRequest = (request) => [
|
|
1431
|
+
`## Request #${request.id}: ${request.subject}`,
|
|
1432
|
+
`- **Status**: ${request.status}${request.type ? ` | **Type**: ${request.type}` : ""}${request.priority ? ` | **Priority**: ${request.priority}` : ""}`,
|
|
1433
|
+
request.ticket_form_id ? `- **Form**: ${request.ticket_form_id}` : "",
|
|
1434
|
+
`- **Can you mark it solved**: ${request.can_be_solved_by_me ? "yes" : "no"}`,
|
|
1435
|
+
request.via?.channel ? `- **Submitted via**: ${request.via.channel}` : "",
|
|
1436
|
+
`- **Created**: ${request.created_at} | **Updated**: ${request.updated_at}`,
|
|
1437
|
+
request.description ? `\n${request.description}` : ""
|
|
1438
|
+
].filter(Boolean).join("\n");
|
|
1439
|
+
const formatRequestComment = (comment, authors) => {
|
|
1440
|
+
const author = authors.get(comment.author_id);
|
|
1441
|
+
const lines = [`### Comment by ${author ? `${author.name}${author.agent ? " (support agent)" : ""}` : `user ${comment.author_id}`}`, `*${comment.created_at}*`];
|
|
1442
|
+
if (comment.attachments?.length) {
|
|
1443
|
+
const summary = comment.attachments.map((a) => `${a.file_name} (#${a.id}, ${a.content_type}) — ${a.content_url}`).join(", ");
|
|
1444
|
+
lines.push(`Attachments: ${summary}`);
|
|
1445
|
+
}
|
|
1446
|
+
lines.push("", comment.body);
|
|
1447
|
+
return lines.join("\n");
|
|
1448
|
+
};
|
|
1326
1449
|
const formatTagDiff = (before, after) => {
|
|
1327
1450
|
const b = new Set(Array.isArray(before) ? before.map(String) : []);
|
|
1328
1451
|
const a = new Set(Array.isArray(after) ? after.map(String) : []);
|
|
@@ -1557,15 +1680,13 @@ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
|
|
|
1557
1680
|
return truncateIfNeeded(text, "This resource takes no parameters; read a long article one part at a time with get_article_outline then get_article_section.");
|
|
1558
1681
|
};
|
|
1559
1682
|
/**
|
|
1560
|
-
* Build an article-resources provider. `listPromoted`
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1683
|
+
* Build an article-resources provider. `listPromoted` memoizes its promise (TTL
|
|
1684
|
+
* `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list` calls a
|
|
1685
|
+
* client makes; `readArticle` is a one-shot fetch. As with
|
|
1563
1686
|
* `createTopologyProvider`, the cache is PER SESSION and must NOT be hoisted to
|
|
1564
|
-
* module scope — in HTTP mode
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
* OAuth/PKCE flow. A 401 notifies `onUnauthorized` (stdio OAuth) to drop the
|
|
1568
|
-
* stale token, mirroring the topology provider and the tool dispatch path.
|
|
1687
|
+
* module scope — in HTTP mode a shared one would leak a caller's data to
|
|
1688
|
+
* another. `getToken` resolves lazily, so connecting never triggers the OAuth
|
|
1689
|
+
* flow, and a 401 notifies `onUnauthorized` to drop the stale token.
|
|
1569
1690
|
*/
|
|
1570
1691
|
const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized) => {
|
|
1571
1692
|
let cached;
|
|
@@ -1834,10 +1955,19 @@ const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
|
|
|
1834
1955
|
};
|
|
1835
1956
|
//#endregion
|
|
1836
1957
|
//#region src/routing/registry.ts
|
|
1958
|
+
/**
|
|
1959
|
+
* The single authority on which tools a config exposes.
|
|
1960
|
+
*
|
|
1961
|
+
* Every filter lives here rather than at the call sites, because there is more
|
|
1962
|
+
* than one consumer -- `registerToolset` registers them and `renderToolSurface`
|
|
1963
|
+
* prints them -- and a filter applied in only one of the two makes
|
|
1964
|
+
* `--print-tools` lie about the running server.
|
|
1965
|
+
*/
|
|
1837
1966
|
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
1838
1967
|
if (options.readOnly && !tool.readOnly) return false;
|
|
1839
1968
|
if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
|
|
1840
1969
|
if (options.tools?.length && !options.tools.includes(tool.name)) return false;
|
|
1970
|
+
if (options.promotedArticles === false && tool.name === "list_promoted_articles") return false;
|
|
1841
1971
|
return true;
|
|
1842
1972
|
});
|
|
1843
1973
|
const groupByNamespace = (tools) => {
|
|
@@ -1849,6 +1979,34 @@ const groupByNamespace = (tools) => {
|
|
|
1849
1979
|
}
|
|
1850
1980
|
return grouped;
|
|
1851
1981
|
};
|
|
1982
|
+
/**
|
|
1983
|
+
* Proxy tool name and title per namespace, used by `namespace` mode.
|
|
1984
|
+
*
|
|
1985
|
+
* Typed `Record<Namespace, …>` rather than `Record<string, …>` so the compiler
|
|
1986
|
+
* rejects this literal outright when a namespace is added to the enum without
|
|
1987
|
+
* a label here. That matters because the consumer looks the label up and skips
|
|
1988
|
+
* the namespace when it is missing: an incomplete map would silently expose no
|
|
1989
|
+
* proxy for a whole namespace, with every existing test still green. Making it
|
|
1990
|
+
* a type error is stronger than any test could be.
|
|
1991
|
+
*/
|
|
1992
|
+
const NAMESPACE_LABELS = {
|
|
1993
|
+
tickets: {
|
|
1994
|
+
toolName: "zendesk_tickets",
|
|
1995
|
+
title: "Zendesk Tickets"
|
|
1996
|
+
},
|
|
1997
|
+
help_center: {
|
|
1998
|
+
toolName: "zendesk_help_center",
|
|
1999
|
+
title: "Zendesk Help Center"
|
|
2000
|
+
},
|
|
2001
|
+
users: {
|
|
2002
|
+
toolName: "zendesk_users",
|
|
2003
|
+
title: "Zendesk Users"
|
|
2004
|
+
},
|
|
2005
|
+
requests: {
|
|
2006
|
+
toolName: "zendesk_requests",
|
|
2007
|
+
title: "Zendesk Requests"
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
1852
2010
|
//#endregion
|
|
1853
2011
|
//#region src/utils/article-order.ts
|
|
1854
2012
|
const hasPositionInversion = (order) => {
|
|
@@ -3117,6 +3275,526 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3117
3275
|
];
|
|
3118
3276
|
};
|
|
3119
3277
|
//#endregion
|
|
3278
|
+
//#region src/tools/attachments.ts
|
|
3279
|
+
/**
|
|
3280
|
+
* File-attachment input, shared by every tool that can carry files on a comment.
|
|
3281
|
+
*
|
|
3282
|
+
* Lives here rather than inside a tool factory because both audiences need it:
|
|
3283
|
+
* an agent attaching a screenshot to a public reply, and an end user attaching a
|
|
3284
|
+
* log to their own request. The Uploads API (`POST /api/v2/uploads.json`) is
|
|
3285
|
+
* documented as allowed for end users, so the same code path serves both.
|
|
3286
|
+
*/
|
|
3287
|
+
const attachmentSchema = z.object({
|
|
3288
|
+
file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
|
|
3289
|
+
file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
|
|
3290
|
+
abort: true,
|
|
3291
|
+
error: (issue) => `Attachment too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split the upload, or link to it instead of uploading.`
|
|
3292
|
+
}).base64().describe(`File content encoded as base64. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file), and the attachments of one call must stay under that total; the HTTP transport additionally caps request bodies at 4 MB.`),
|
|
3293
|
+
content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
|
|
3294
|
+
});
|
|
3295
|
+
/**
|
|
3296
|
+
* The optional `attachments` array parameter, capped as a whole.
|
|
3297
|
+
*
|
|
3298
|
+
* The per-file cap in `attachmentSchema` bounds one attachment; this bounds a
|
|
3299
|
+
* call, because `attachments` is a list and every file rides in the same
|
|
3300
|
+
* message (#205).
|
|
3301
|
+
*/
|
|
3302
|
+
const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
|
|
3303
|
+
const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
|
|
3304
|
+
if (total > 10420224) refinement.addIssue({
|
|
3305
|
+
code: "custom",
|
|
3306
|
+
message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
|
|
3307
|
+
});
|
|
3308
|
+
}).optional().describe(description);
|
|
3309
|
+
/**
|
|
3310
|
+
* Upload each file via the Zendesk Uploads API, aggregating them under a single
|
|
3311
|
+
* upload token (the token from the first upload is passed to the next), and
|
|
3312
|
+
* return that token for use in a comment's `uploads` array. Sequential of
|
|
3313
|
+
* necessity: each call needs the previous token.
|
|
3314
|
+
*/
|
|
3315
|
+
const uploadAttachments = async (subdomain, token, files) => {
|
|
3316
|
+
let uploadToken;
|
|
3317
|
+
for (const file of files) {
|
|
3318
|
+
const { upload } = await zendeskUpload(subdomain, token, file.file_name, Buffer.from(file.file_base64, "base64"), file.content_type, uploadToken);
|
|
3319
|
+
uploadToken = upload.token;
|
|
3320
|
+
}
|
|
3321
|
+
return uploadToken;
|
|
3322
|
+
};
|
|
3323
|
+
const formatAttachmentSuffix = (count) => count ? ` with ${count} attachment(s)` : "";
|
|
3324
|
+
//#endregion
|
|
3325
|
+
//#region src/tools/requests.ts
|
|
3326
|
+
const forbidden = (tool, endpoint, error, hint) => new Error(`${tool} reads the end-user Requests surface (${endpoint}), which Zendesk serves to a signed-in requester. The current token was refused (HTTP 403). Either the Help Center is closed to this account, or this token is not a Help-Center-enabled user.${hint ? ` ${hint}` : ""} For the agent-side equivalents use create_ticket, get_ticket, list_tickets or add_public_comment (namespace: tickets).`, { cause: error });
|
|
3327
|
+
const OTHER_USERS_REQUEST_HINT = "A request id that belongs to another user is refused the same way, so check the id is one of your own.";
|
|
3328
|
+
/** Run `fetch`, rewriting a 403 into guidance naming the end-user surface. */
|
|
3329
|
+
const withForbiddenGuidance = async (tool, endpoint, fetch, hint) => {
|
|
3330
|
+
try {
|
|
3331
|
+
return await fetch();
|
|
3332
|
+
} catch (error) {
|
|
3333
|
+
if (error instanceof ZendeskApiError && error.status === 403) throw forbidden(tool, endpoint, error, hint);
|
|
3334
|
+
throw error;
|
|
3335
|
+
}
|
|
3336
|
+
};
|
|
3337
|
+
const END_USER_FORM_PARAMS = {
|
|
3338
|
+
active: "true",
|
|
3339
|
+
end_user_visible: "true",
|
|
3340
|
+
fallback_to_default: "true"
|
|
3341
|
+
};
|
|
3342
|
+
/**
|
|
3343
|
+
* Walk an offset-paginated listing to the end, following `next_page` and
|
|
3344
|
+
* nothing else.
|
|
3345
|
+
*
|
|
3346
|
+
* `next_page` is the only trustworthy end-of-list signal on these endpoints:
|
|
3347
|
+
* `count` can be the pre-filter total, and a short page can still be followed
|
|
3348
|
+
* by another. Hitting the cap throws rather than returning a partial list --
|
|
3349
|
+
* for both callers a missing entry is worse than an error, because it makes a
|
|
3350
|
+
* form invisible or a required field unenforced, and the caller then gets a
|
|
3351
|
+
* confidently wrong "no such form" or an opaque Zendesk 422.
|
|
3352
|
+
*/
|
|
3353
|
+
const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {}, maxPages = TICKET_FIELD_SCAN_MAX_PAGES, capEnvVar = "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", onPage, forbiddenHint }) => {
|
|
3354
|
+
const items = [];
|
|
3355
|
+
let page = 1;
|
|
3356
|
+
while (true) {
|
|
3357
|
+
const response = await withForbiddenGuidance(tool, `GET ${path}`, () => zendeskGet(subdomain, token, path, {
|
|
3358
|
+
...params,
|
|
3359
|
+
...buildOffsetParams(100, page)
|
|
3360
|
+
}), forbiddenHint);
|
|
3361
|
+
items.push(...extract(response) ?? []);
|
|
3362
|
+
onPage?.(response);
|
|
3363
|
+
if (!response.next_page) return items;
|
|
3364
|
+
if (page >= maxPages) throw new Error(`${tool} could not read all of ${path}: the account exposes more than ${maxPages} pages of it. Continuing from a partial list would hide entries and produce a confidently wrong answer, so this stops here. Raise ${capEnvVar} and retry.`);
|
|
3365
|
+
page += 1;
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
const fetchEndUserForms = async (subdomain, token, tool) => fetchAllPages({
|
|
3369
|
+
subdomain,
|
|
3370
|
+
token,
|
|
3371
|
+
tool,
|
|
3372
|
+
path: "/ticket_forms",
|
|
3373
|
+
extract: (response) => response.ticket_forms,
|
|
3374
|
+
params: END_USER_FORM_PARAMS,
|
|
3375
|
+
forbiddenHint: "This endpoint can also be unavailable on a Zendesk plan without multiple ticket forms."
|
|
3376
|
+
});
|
|
3377
|
+
/**
|
|
3378
|
+
* Every ticket field the caller can see, paged defensively.
|
|
3379
|
+
*
|
|
3380
|
+
* `GET /ticket_fields` cannot be trusted to say when it is done. Under an
|
|
3381
|
+
* end-user token its `count` is the UNFILTERED total (27 while returning 8
|
|
3382
|
+
* objects with `next_page: null`), and in cursor mode `page[size]` is applied
|
|
3383
|
+
* before the visibility filter, so a short page is not the last page. So this
|
|
3384
|
+
* follows `next_page` and nothing else, bounded by a page cap.
|
|
3385
|
+
*
|
|
3386
|
+
* The per-field lookup would be the obvious alternative and is not available:
|
|
3387
|
+
* `GET /ticket_fields/{id}` answers 403 for an end user. The list is the only
|
|
3388
|
+
* way in.
|
|
3389
|
+
*/
|
|
3390
|
+
const fetchVisibleTicketFields = async (subdomain, token, tool) => {
|
|
3391
|
+
return (await fetchAllPages({
|
|
3392
|
+
subdomain,
|
|
3393
|
+
token,
|
|
3394
|
+
tool,
|
|
3395
|
+
path: "/ticket_fields",
|
|
3396
|
+
extract: (response) => response.ticket_fields
|
|
3397
|
+
})).filter((field) => field.visible_in_portal !== false);
|
|
3398
|
+
};
|
|
3399
|
+
const fetchAllRequestComments = async (subdomain, token, requestId) => {
|
|
3400
|
+
const authors = /* @__PURE__ */ new Map();
|
|
3401
|
+
return {
|
|
3402
|
+
comments: await fetchAllPages({
|
|
3403
|
+
subdomain,
|
|
3404
|
+
token,
|
|
3405
|
+
tool: "get_request",
|
|
3406
|
+
path: `/requests/${requestId}/comments`,
|
|
3407
|
+
extract: (response) => response.comments,
|
|
3408
|
+
params: { include: "users" },
|
|
3409
|
+
maxPages: MAX_COMMENT_PAGES,
|
|
3410
|
+
capEnvVar: "ZENDESK_MAX_COMMENT_PAGES",
|
|
3411
|
+
forbiddenHint: OTHER_USERS_REQUEST_HINT,
|
|
3412
|
+
onPage: (response) => {
|
|
3413
|
+
for (const user of response.users ?? []) authors.set(user.id, user);
|
|
3414
|
+
}
|
|
3415
|
+
}),
|
|
3416
|
+
authors
|
|
3417
|
+
};
|
|
3418
|
+
};
|
|
3419
|
+
const SYSTEM_FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
3420
|
+
"subject",
|
|
3421
|
+
"description",
|
|
3422
|
+
"status",
|
|
3423
|
+
"tickettype",
|
|
3424
|
+
"priority",
|
|
3425
|
+
"group",
|
|
3426
|
+
"assignee",
|
|
3427
|
+
"tags",
|
|
3428
|
+
"custom_status"
|
|
3429
|
+
]);
|
|
3430
|
+
const isCustomField = (field) => !SYSTEM_FIELD_TYPES.has(field.type);
|
|
3431
|
+
const formSummary = (form) => [`- **${form.display_name || form.name}** (form id ${form.id})${form.default ? " — default" : ""}`, form.display_name && form.display_name !== form.name ? ` - Internal name: ${form.name}` : ""].filter(Boolean).join("\n");
|
|
3432
|
+
const fieldSpec = (field) => {
|
|
3433
|
+
const options = field.custom_field_options ?? field.system_field_options ?? [];
|
|
3434
|
+
return [
|
|
3435
|
+
`### ${field.title_in_portal || field.title} (field id ${field.id})`,
|
|
3436
|
+
`- **Type**: ${field.type} | **${field.required_in_portal ? "required" : "optional"}**`,
|
|
3437
|
+
field.description ? `- **Help text**: ${field.description}` : "",
|
|
3438
|
+
options.length > 0 ? "- **Accepted values** (label → value to send):" : "",
|
|
3439
|
+
...options.map((option) => ` - ${option.name} → ${option.value}`)
|
|
3440
|
+
].filter(Boolean).join("\n");
|
|
3441
|
+
};
|
|
3442
|
+
const fieldRef = (id, byId) => {
|
|
3443
|
+
const field = byId.get(id);
|
|
3444
|
+
return field ? `${field.title_in_portal || field.title} (field id ${id})` : `field ${id}`;
|
|
3445
|
+
};
|
|
3446
|
+
/**
|
|
3447
|
+
* Whether a conditional child field is required *of a submitter*, in words.
|
|
3448
|
+
*
|
|
3449
|
+
* `is_required` alone is not the answer: `required_on_statuses` narrows it to
|
|
3450
|
+
* particular statuses, and saying "then required" for a field required only
|
|
3451
|
+
* "when open" sends the assistant hunting for an answer Zendesk will not ask
|
|
3452
|
+
* for. A new request starts at `new`, so that is the status read for -- but a
|
|
3453
|
+
* trigger can move it off `new` at once, hence wording that says when the field
|
|
3454
|
+
* becomes required rather than that it never will.
|
|
3455
|
+
*/
|
|
3456
|
+
const childRequirement = (child) => {
|
|
3457
|
+
if (!child.is_required) return "";
|
|
3458
|
+
const scope = child.required_on_statuses;
|
|
3459
|
+
if (!scope || scope.type === "ALL_STATUSES") return " (then required)";
|
|
3460
|
+
if (scope.type === "NO_STATUSES") return "";
|
|
3461
|
+
const statuses = scope.statuses ?? [];
|
|
3462
|
+
if (statuses.length === 0) return " (then required)";
|
|
3463
|
+
return statuses.includes("new") ? " (then required)" : ` (then required once the request is ${statuses.join(" or ")}, not to submit it)`;
|
|
3464
|
+
};
|
|
3465
|
+
const conditionSpec = (condition, byId) => {
|
|
3466
|
+
const children = (condition.child_fields ?? []).map((child) => `${fieldRef(child.id, byId)}${childRequirement(child)}`).join(", ");
|
|
3467
|
+
return `- When ${fieldRef(condition.parent_field_id, byId)} is \`${String(condition.value)}\`: ${children || "no additional fields"}`;
|
|
3468
|
+
};
|
|
3469
|
+
const renderFormSpec = (form, fields) => {
|
|
3470
|
+
const byId = new Map(fields.map((field) => [field.id, field]));
|
|
3471
|
+
const formFields = form.ticket_field_ids.map((id) => byId.get(id)).filter((field) => field !== void 0).filter(isCustomField);
|
|
3472
|
+
const required = formFields.filter((field) => field.required_in_portal);
|
|
3473
|
+
const conditions = form.end_user_conditions ?? [];
|
|
3474
|
+
return [
|
|
3475
|
+
`# ${form.display_name || form.name} (form id ${form.id})`,
|
|
3476
|
+
"",
|
|
3477
|
+
"Always needed: a subject and a description (the `subject` and `body` parameters",
|
|
3478
|
+
"of create_request). The fields below are what this form asks for on top of those.",
|
|
3479
|
+
"",
|
|
3480
|
+
required.length > 0 ? `**Required**: ${required.map((f) => f.title_in_portal || f.title).join(", ")}` : "**Required**: nothing beyond the subject and description.",
|
|
3481
|
+
conditions.length > 0 ? [
|
|
3482
|
+
"",
|
|
3483
|
+
"## Conditional fields",
|
|
3484
|
+
"Some fields appear, or become required, only for certain answers. Ask for them once",
|
|
3485
|
+
"the controlling answer is known rather than up front:",
|
|
3486
|
+
...conditions.map((condition) => conditionSpec(condition, byId))
|
|
3487
|
+
].join("\n") : "",
|
|
3488
|
+
"",
|
|
3489
|
+
"## Fields",
|
|
3490
|
+
"",
|
|
3491
|
+
formFields.length > 0 ? formFields.map(fieldSpec).join("\n\n") : "This form exposes no custom fields; send subject and body only."
|
|
3492
|
+
].filter(Boolean).join("\n");
|
|
3493
|
+
};
|
|
3494
|
+
const isEmptyAnswer = (value) => value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
3495
|
+
const optionValues = (field) => field.custom_field_options?.length ? new Set(field.custom_field_options.map((option) => option.value)) : null;
|
|
3496
|
+
/**
|
|
3497
|
+
* Refuse a submission the API would accept and quietly mangle. Three ways it
|
|
3498
|
+
* does: an unknown `ticket_form_id` gets 201 on the DEFAULT form; a missing
|
|
3499
|
+
* `required_in_portal` field is enforced against end users only, so an agent
|
|
3500
|
+
* token gets 201 with an empty subject; and an option value the form does not
|
|
3501
|
+
* offer is dropped, leaving 201 and an empty field.
|
|
3502
|
+
*
|
|
3503
|
+
* Only UNCONDITIONALLY required fields are enforced -- one required through
|
|
3504
|
+
* `end_user_conditions` depends on answers we may not have, and Zendesk's 422
|
|
3505
|
+
* is the backstop.
|
|
3506
|
+
*/
|
|
3507
|
+
const validateSubmission = (form, fields, provided) => {
|
|
3508
|
+
const byId = new Map(fields.map((field) => [field.id, field]));
|
|
3509
|
+
const formFieldIds = new Set(form.ticket_field_ids);
|
|
3510
|
+
const offForm = provided.filter((entry) => {
|
|
3511
|
+
const field = byId.get(entry.id);
|
|
3512
|
+
return !formFieldIds.has(entry.id) || !field || !isCustomField(field);
|
|
3513
|
+
});
|
|
3514
|
+
if (offForm.length > 0) throw new Error(`This form does not take those fields: ${offForm.map((entry) => entry.id).join(", ")}. Zendesk would accept the submission and drop them. Call get_request_form for the ids it does take; the subject and the description are this tool's own parameters, not custom_fields entries.`);
|
|
3515
|
+
const answered = provided.filter((entry) => !isEmptyAnswer(entry.value));
|
|
3516
|
+
const providedIds = new Set(answered.map((e) => e.id));
|
|
3517
|
+
const missing = form.ticket_field_ids.map((id) => byId.get(id)).filter((field) => field?.required_in_portal === true).filter(isCustomField).filter((field) => !providedIds.has(field.id));
|
|
3518
|
+
if (missing.length > 0) {
|
|
3519
|
+
const list = missing.map((field) => `${field.title_in_portal || field.title} (field id ${field.id})`).join(", ");
|
|
3520
|
+
throw new Error(`This form requires values the submission does not carry: ${list}. Ask for them, then resend with those ids in custom_fields. Call get_request_form for their accepted values.`);
|
|
3521
|
+
}
|
|
3522
|
+
const rejected = answered.flatMap((entry) => {
|
|
3523
|
+
const field = byId.get(entry.id);
|
|
3524
|
+
const accepted = field && optionValues(field);
|
|
3525
|
+
if (!field || !accepted) return [];
|
|
3526
|
+
const unknown = (Array.isArray(entry.value) ? entry.value : [entry.value]).filter((value) => typeof value !== "string" || !accepted.has(value));
|
|
3527
|
+
return unknown.length > 0 ? [`${field.title_in_portal || field.title} (field id ${field.id}) got ${unknown.map((value) => JSON.stringify(value)).join(", ")}, accepts ${[...accepted].map((value) => JSON.stringify(value)).join(", ")}`] : [];
|
|
3528
|
+
});
|
|
3529
|
+
if (rejected.length > 0) throw new Error(`This form does not offer those answers: ${rejected.join("; ")}. Zendesk would accept the submission and drop them, leaving the request without the answers it asked for, so this is refused instead. Call get_request_form for the exact values to send.`);
|
|
3530
|
+
};
|
|
3531
|
+
const REQUEST_STATUS = z.enum([
|
|
3532
|
+
"new",
|
|
3533
|
+
"open",
|
|
3534
|
+
"pending",
|
|
3535
|
+
"hold",
|
|
3536
|
+
"solved",
|
|
3537
|
+
"closed"
|
|
3538
|
+
]);
|
|
3539
|
+
const createRequestTools = (ctx) => {
|
|
3540
|
+
const { subdomain, getToken } = ctx;
|
|
3541
|
+
const resolveForm = async (tool, token, formId) => {
|
|
3542
|
+
const [forms, fields] = await Promise.all([fetchEndUserForms(subdomain, token, tool), fetchVisibleTicketFields(subdomain, token, tool)]);
|
|
3543
|
+
if (forms.length === 0) throw new Error(`No request form is available to this user on ${subdomain}.zendesk.com. The Help Center may be closed, or no form is marked visible to end users. An agent can still open a ticket with create_ticket (namespace: tickets).`);
|
|
3544
|
+
const form = formId === void 0 ? forms.find((f) => f.default) ?? (forms.length === 1 ? forms[0] : void 0) : forms.find((f) => f.id === formId);
|
|
3545
|
+
if (!form) {
|
|
3546
|
+
const available = forms.map((f) => `${f.display_name || f.name} (${f.id})`).join(", ");
|
|
3547
|
+
throw new Error(formId === void 0 ? `No default request form on ${subdomain}.zendesk.com. Pick one explicitly: ${available}.` : `No request form with id ${formId} is available to you. Available: ${available}. Sending an unknown id would make Zendesk silently substitute the default form, so this is refused rather than guessed.`);
|
|
3548
|
+
}
|
|
3549
|
+
return {
|
|
3550
|
+
form,
|
|
3551
|
+
fields
|
|
3552
|
+
};
|
|
3553
|
+
};
|
|
3554
|
+
return [
|
|
3555
|
+
{
|
|
3556
|
+
name: "list_request_forms",
|
|
3557
|
+
namespace: "requests",
|
|
3558
|
+
readOnly: true,
|
|
3559
|
+
title: "List Request Forms",
|
|
3560
|
+
description: "List the kinds of request you can submit to this Zendesk — the forms a customer picks between on the Help Center, such as \"Report a bug\" or \"Feature request\". Returns each form's customer-facing name, its numeric id, and which one is the account default. Call this first when opening a request so the user chooses the right kind, then get_request_form to learn what that form asks for. Only forms that are active and visible to end users are listed; an account with a single form returns just that one. This is the end-user view: agents picking a form for someone else want list_ticket_fields and create_ticket instead.",
|
|
3561
|
+
inputSchema: z.object({}),
|
|
3562
|
+
annotations: {
|
|
3563
|
+
readOnlyHint: true,
|
|
3564
|
+
destructiveHint: false,
|
|
3565
|
+
idempotentHint: true,
|
|
3566
|
+
openWorldHint: true
|
|
3567
|
+
},
|
|
3568
|
+
handler: async () => {
|
|
3569
|
+
const token = await getToken();
|
|
3570
|
+
const forms = await fetchEndUserForms(subdomain, token, "list_request_forms");
|
|
3571
|
+
if (forms.length === 0) return { content: [{
|
|
3572
|
+
type: "text",
|
|
3573
|
+
text: "No request form is available to you on this Zendesk. The Help Center may be closed, or no form is marked visible to end users."
|
|
3574
|
+
}] };
|
|
3575
|
+
const text = [
|
|
3576
|
+
`${forms.length} kind(s) of request available:`,
|
|
3577
|
+
"",
|
|
3578
|
+
forms.map(formSummary).join("\n"),
|
|
3579
|
+
"",
|
|
3580
|
+
"Call get_request_form with a form id to see the questions it asks."
|
|
3581
|
+
].join("\n");
|
|
3582
|
+
return { content: [{
|
|
3583
|
+
type: "text",
|
|
3584
|
+
text: truncateIfNeeded(text, "list_request_forms takes no parameters, so this listing cannot be narrowed from the call; read one form in full with get_request_form.")
|
|
3585
|
+
}] };
|
|
3586
|
+
}
|
|
3587
|
+
},
|
|
3588
|
+
{
|
|
3589
|
+
name: "get_request_form",
|
|
3590
|
+
namespace: "requests",
|
|
3591
|
+
readOnly: true,
|
|
3592
|
+
title: "Get Request Form",
|
|
3593
|
+
description: "Read what one request form actually asks for, so the user can be walked through it question by question. Returns the fields on that form in the order the portal shows them, each with its customer-facing label, whether it is required, and for dropdowns the exact values Zendesk accepts — plus any conditional rules (\"if Type is Bug, Version becomes required\"). Joins the form definition with the field definitions, because the form itself carries only field ids. Use it after list_request_forms and before create_request; omit form_id to describe the account default form. Conditional fields are reported as rules rather than resolved, since which ones apply depends on answers not yet given.",
|
|
3594
|
+
inputSchema: z.object({ form_id: z.number().int().optional().describe("Form id whose questions to read, as returned by list_request_forms. Omit to describe the account default form.") }),
|
|
3595
|
+
annotations: {
|
|
3596
|
+
readOnlyHint: true,
|
|
3597
|
+
destructiveHint: false,
|
|
3598
|
+
idempotentHint: true,
|
|
3599
|
+
openWorldHint: true
|
|
3600
|
+
},
|
|
3601
|
+
handler: async (params) => {
|
|
3602
|
+
const { form_id } = params;
|
|
3603
|
+
const token = await getToken();
|
|
3604
|
+
const { form, fields } = await resolveForm("get_request_form", token, form_id);
|
|
3605
|
+
return { content: [{
|
|
3606
|
+
type: "text",
|
|
3607
|
+
text: truncateIfNeeded(renderFormSpec(form, fields), "get_request_form takes only form_id, so this description cannot be narrowed from the call; the fields it lists are the ones this form asks for.")
|
|
3608
|
+
}] };
|
|
3609
|
+
}
|
|
3610
|
+
},
|
|
3611
|
+
{
|
|
3612
|
+
name: "create_request",
|
|
3613
|
+
namespace: "requests",
|
|
3614
|
+
readOnly: false,
|
|
3615
|
+
title: "Submit a Request",
|
|
3616
|
+
description: "Submit a new support request as the signed-in user — the MCP equivalent of the Help Center's \"Submit a request\" form. Returns the created request with its number, which the user can then follow with list_requests and get_request. The submission is checked before sending: an unknown form id, a missing field the form marks required, or a dropdown answer the form does not offer is refused here rather than sent, because Zendesk would answer 201 having silently substituted the default form, accepted an empty subject or dropped the unrecognised value. Call get_request_form first to learn which fields to gather. Priority and type cannot be set by an end user — Zendesk drops them — so triage is left to the agents. This posts as the authenticated user; an agent opening a ticket on someone else's behalf wants create_ticket instead.",
|
|
3617
|
+
inputSchema: z.object({
|
|
3618
|
+
subject: z.string().min(1).describe("One-line summary of the request, shown as the ticket title to the support agents who pick it up."),
|
|
3619
|
+
body: z.string().min(1).describe("The request itself: what happened, what was expected, and any steps to reproduce. Becomes the first public comment on the ticket."),
|
|
3620
|
+
form_id: z.number().int().optional().describe("Form id chosen from list_request_forms, deciding which questions apply. Omit to use the account default form."),
|
|
3621
|
+
custom_fields: z.array(z.object({
|
|
3622
|
+
id: z.number().int(),
|
|
3623
|
+
value: z.unknown()
|
|
3624
|
+
})).optional().describe("Answers to the form's own questions, as { id, value } pairs. Take both the ids and the accepted values from get_request_form; an id this form does not carry, or a value a dropdown does not offer, is refused here because Zendesk would drop it without an error. Option values are the tag strings, never numbers."),
|
|
3625
|
+
attachments: attachmentsParam("Files to attach to the request, such as a screenshot or a log, with their content base64-encoded.")
|
|
3626
|
+
}),
|
|
3627
|
+
annotations: {
|
|
3628
|
+
readOnlyHint: false,
|
|
3629
|
+
destructiveHint: false,
|
|
3630
|
+
idempotentHint: false,
|
|
3631
|
+
openWorldHint: true
|
|
3632
|
+
},
|
|
3633
|
+
handler: async (params) => {
|
|
3634
|
+
const { subject, body, form_id, custom_fields, attachments } = params;
|
|
3635
|
+
const token = await getToken();
|
|
3636
|
+
const { form, fields } = await resolveForm("create_request", token, form_id);
|
|
3637
|
+
validateSubmission(form, fields, custom_fields ?? []);
|
|
3638
|
+
const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
|
|
3639
|
+
const { request } = await withForbiddenGuidance("create_request", "POST /requests", () => zendeskPost(subdomain, token, "/requests", { request: {
|
|
3640
|
+
subject,
|
|
3641
|
+
ticket_form_id: form.id,
|
|
3642
|
+
comment: {
|
|
3643
|
+
body,
|
|
3644
|
+
...uploads && { uploads }
|
|
3645
|
+
},
|
|
3646
|
+
...custom_fields && { custom_fields }
|
|
3647
|
+
} }));
|
|
3648
|
+
const suffix = formatAttachmentSuffix(attachments?.length);
|
|
3649
|
+
return { content: [{
|
|
3650
|
+
type: "text",
|
|
3651
|
+
text: `Request #${request.id} submitted${suffix}.\n\n${formatRequest(request)}`
|
|
3652
|
+
}] };
|
|
3653
|
+
}
|
|
3654
|
+
},
|
|
3655
|
+
{
|
|
3656
|
+
name: "list_requests",
|
|
3657
|
+
namespace: "requests",
|
|
3658
|
+
readOnly: true,
|
|
3659
|
+
title: "List My Requests",
|
|
3660
|
+
description: "List the requests the signed-in user submitted, newest activity first by default. Returns each request with its number, subject, status and whether the user can mark it solved; pass a query to search their own requests by free text instead of listing all of them. Scoped to the caller by Zendesk itself — it can never return anyone else's tickets — which is why an agent calling this sees only requests they raised, not their queue. Use get_request to read one with its full conversation. Agents wanting a queue want list_tickets, search_tickets or get_view_tickets instead.",
|
|
3661
|
+
inputSchema: z.object({
|
|
3662
|
+
query: z.string().min(1).optional().describe("Free text to match against the caller's own requests. When given, the search endpoint is used instead of the plain listing; omit it to list everything."),
|
|
3663
|
+
status: z.array(REQUEST_STATUS).min(1).optional().describe("Restrict to these ticket states, e.g. [\"open\",\"pending\"] for anything still being worked. Validated here because Zendesk silently returns every request when it does not recognise a status."),
|
|
3664
|
+
sort_by: z.enum(["created_at", "updated_at"]).default("updated_at").describe("Which timestamp orders the results: updated_at surfaces recent activity, created_at the newest submissions."),
|
|
3665
|
+
sort_order: z.enum(["asc", "desc"]).default("desc").describe("Direction of that ordering; desc puts the most recent first, which is what a user following their tickets wants."),
|
|
3666
|
+
per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
3667
|
+
page: z.number().int().min(1).default(1).describe(PAGE_DESC)
|
|
3668
|
+
}),
|
|
3669
|
+
annotations: {
|
|
3670
|
+
readOnlyHint: true,
|
|
3671
|
+
destructiveHint: false,
|
|
3672
|
+
idempotentHint: true,
|
|
3673
|
+
openWorldHint: true
|
|
3674
|
+
},
|
|
3675
|
+
handler: async (params) => {
|
|
3676
|
+
const { query, status, sort_by, sort_order, per_page, page } = params;
|
|
3677
|
+
const token = await getToken();
|
|
3678
|
+
const path = query ? "/requests/search" : "/requests";
|
|
3679
|
+
const response = await withForbiddenGuidance("list_requests", `GET ${path}`, () => zendeskGet(subdomain, token, path, {
|
|
3680
|
+
...query && { query },
|
|
3681
|
+
...status?.length && { status: status.join(",") },
|
|
3682
|
+
sort_by,
|
|
3683
|
+
sort_order,
|
|
3684
|
+
...buildOffsetParams(per_page, page)
|
|
3685
|
+
}));
|
|
3686
|
+
const requests = response.requests ?? [];
|
|
3687
|
+
const meta = extractOffsetPaginationMeta(response, requests.length, per_page, page);
|
|
3688
|
+
if (requests.length === 0) return { content: [{
|
|
3689
|
+
type: "text",
|
|
3690
|
+
text: query ? `No request of yours matches "${query}".` : "You have not submitted any request matching this filter."
|
|
3691
|
+
}] };
|
|
3692
|
+
return { content: [{
|
|
3693
|
+
type: "text",
|
|
3694
|
+
text: `${query ? `Searched your requests for "${query}".` : "Your requests."}\n\n${formatList(requests, formatRequest, meta)}`
|
|
3695
|
+
}] };
|
|
3696
|
+
}
|
|
3697
|
+
},
|
|
3698
|
+
{
|
|
3699
|
+
name: "get_request",
|
|
3700
|
+
namespace: "requests",
|
|
3701
|
+
readOnly: true,
|
|
3702
|
+
title: "Get My Request",
|
|
3703
|
+
description: "Read one of the signed-in user's own requests, optionally with the whole conversation on it. Returns the request's status, the form it was submitted on, whether the user may mark it solved, and — with include_comments — every public message on it, each labelled with its author and marked when that author is a support agent. Internal agent notes are never returned on this path, so nothing agent-private can leak through it. A request belonging to someone else answers \"permission denied\" rather than \"not found\", so a denial here does not tell you whether that id exists. Find the number with list_requests; agents reading a ticket they do not own want get_ticket instead.",
|
|
3704
|
+
inputSchema: z.object({
|
|
3705
|
+
request_id: z.number().int().describe("Number of the request to read, as shown by list_requests or returned when it was submitted."),
|
|
3706
|
+
include_comments: z.boolean().default(false).describe("When true, appends the full public conversation — agent replies and the user's own messages. Defaults to false so a status check stays cheap.")
|
|
3707
|
+
}),
|
|
3708
|
+
annotations: {
|
|
3709
|
+
readOnlyHint: true,
|
|
3710
|
+
destructiveHint: false,
|
|
3711
|
+
idempotentHint: true,
|
|
3712
|
+
openWorldHint: true
|
|
3713
|
+
},
|
|
3714
|
+
handler: async (params) => {
|
|
3715
|
+
const { request_id, include_comments } = params;
|
|
3716
|
+
const token = await getToken();
|
|
3717
|
+
const { request } = await withForbiddenGuidance("get_request", `GET /requests/${request_id}`, () => zendeskGet(subdomain, token, `/requests/${request_id}`), OTHER_USERS_REQUEST_HINT);
|
|
3718
|
+
let text = formatRequest(request);
|
|
3719
|
+
if (include_comments) {
|
|
3720
|
+
const { comments, authors } = await fetchAllRequestComments(subdomain, token, request_id);
|
|
3721
|
+
const thread = comments.map((c) => formatRequestComment(c, authors)).join("\n\n");
|
|
3722
|
+
text += `\n\n---\n# Conversation\n\n${thread}`;
|
|
3723
|
+
}
|
|
3724
|
+
const advice = include_comments ? `get_request appends the whole conversation as one unpaginated block; call it again with include_comments false to read request #${request_id}'s status and description alone.` : "get_request takes no pagination or filter parameters, so this response cannot be narrowed from the call.";
|
|
3725
|
+
return { content: [{
|
|
3726
|
+
type: "text",
|
|
3727
|
+
text: truncateIfNeeded(text, advice)
|
|
3728
|
+
}] };
|
|
3729
|
+
}
|
|
3730
|
+
},
|
|
3731
|
+
{
|
|
3732
|
+
name: "add_request_comment",
|
|
3733
|
+
namespace: "requests",
|
|
3734
|
+
readOnly: false,
|
|
3735
|
+
title: "Reply on My Request",
|
|
3736
|
+
description: "Reply on one of the signed-in user's own requests, optionally attaching files. Returns confirmation with the request's status after the reply, which matters because replying on a request that was already solved REOPENS it — Zendesk moves it back to open, and the user should know that is what their message did. The comment is always public: end users cannot post internal notes, and Zendesk silently ignores any attempt to mark one private. A closed request rejects new comments outright. Agents replying to a requester want add_public_comment, or add_private_note for an internal note.",
|
|
3737
|
+
inputSchema: z.object({
|
|
3738
|
+
request_id: z.number().int().describe("Number of the request to reply on, as shown by list_requests."),
|
|
3739
|
+
body: z.string().min(1).describe("The message to send. Visible to the support agents on the ticket and included in their email notification."),
|
|
3740
|
+
attachments: attachmentsParam("Files to attach to this reply, such as a screenshot or a log, with their content base64-encoded.")
|
|
3741
|
+
}),
|
|
3742
|
+
annotations: {
|
|
3743
|
+
readOnlyHint: false,
|
|
3744
|
+
destructiveHint: false,
|
|
3745
|
+
idempotentHint: false,
|
|
3746
|
+
openWorldHint: true
|
|
3747
|
+
},
|
|
3748
|
+
handler: async (params) => {
|
|
3749
|
+
const { request_id, body, attachments } = params;
|
|
3750
|
+
const token = await getToken();
|
|
3751
|
+
const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
|
|
3752
|
+
const { request } = await withForbiddenGuidance("add_request_comment", `PUT /requests/${request_id}`, () => zendeskPut(subdomain, token, `/requests/${request_id}`, { request: { comment: {
|
|
3753
|
+
body,
|
|
3754
|
+
...uploads && { uploads }
|
|
3755
|
+
} } }), OTHER_USERS_REQUEST_HINT);
|
|
3756
|
+
return { content: [{
|
|
3757
|
+
type: "text",
|
|
3758
|
+
text: `Reply added to request #${request_id}${formatAttachmentSuffix(attachments?.length)}. It is now **${request.status}**.`
|
|
3759
|
+
}] };
|
|
3760
|
+
}
|
|
3761
|
+
},
|
|
3762
|
+
{
|
|
3763
|
+
name: "mark_request_solved",
|
|
3764
|
+
namespace: "requests",
|
|
3765
|
+
readOnly: false,
|
|
3766
|
+
title: "Mark My Request Solved",
|
|
3767
|
+
description: "Close one of the signed-in user's own requests, for when they consider it resolved. Returns the request's status read back from Zendesk, not merely an acknowledgement: this operation is refused unless the request is actually solvable by its requester, because Zendesk answers 200 and changes nothing when it is not, which would otherwise be reported as success. A request is only solvable once an agent has been assigned to it, so a brand-new or unassigned one cannot be closed this way — leave it, or reply with add_request_comment to say it is no longer needed. Marking an already-solved request solved again is a no-op. Agents closing a ticket want update_ticket with status solved.",
|
|
3768
|
+
inputSchema: z.object({ request_id: z.number().int().describe("Number of the request to close. Check get_request first: its \"can you mark it solved\" line has to say yes.") }),
|
|
3769
|
+
annotations: {
|
|
3770
|
+
readOnlyHint: false,
|
|
3771
|
+
destructiveHint: true,
|
|
3772
|
+
idempotentHint: true,
|
|
3773
|
+
openWorldHint: true
|
|
3774
|
+
},
|
|
3775
|
+
handler: async (params) => {
|
|
3776
|
+
const { request_id } = params;
|
|
3777
|
+
const token = await getToken();
|
|
3778
|
+
const endpoint = `/requests/${request_id}`;
|
|
3779
|
+
const { request: before } = await withForbiddenGuidance("mark_request_solved", `GET ${endpoint}`, () => zendeskGet(subdomain, token, endpoint), OTHER_USERS_REQUEST_HINT);
|
|
3780
|
+
if (before.status === "solved" || before.status === "closed") return { content: [{
|
|
3781
|
+
type: "text",
|
|
3782
|
+
text: `Request #${request_id} is already **${before.status}**. Nothing to do.`
|
|
3783
|
+
}] };
|
|
3784
|
+
if (!before.can_be_solved_by_me) return { content: [{
|
|
3785
|
+
type: "text",
|
|
3786
|
+
text: `Request #${request_id} cannot be marked solved by you: Zendesk only allows that once an agent has been assigned to it. It is currently **${before.status}**. Sending it anyway would return success and change nothing, so it was not sent. Use add_request_comment to tell the agents it is resolved on your side.`
|
|
3787
|
+
}] };
|
|
3788
|
+
const { request: after } = await withForbiddenGuidance("mark_request_solved", `PUT ${endpoint}`, () => zendeskPut(subdomain, token, endpoint, { request: { solved: true } }), OTHER_USERS_REQUEST_HINT);
|
|
3789
|
+
return { content: [{
|
|
3790
|
+
type: "text",
|
|
3791
|
+
text: after.status === "solved" ? `Request #${request_id} is now **solved**.` : `Zendesk accepted the update but request #${request_id} is still **${after.status}**, so it was not solved. Two causes produce this silently: the token belongs to an agent rather than to the requester -- Zendesk answers 200 and ignores \`solved\` for them -- or the assignment changed between the check and the update. Re-read it with get_request.`
|
|
3792
|
+
}] };
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
];
|
|
3796
|
+
};
|
|
3797
|
+
//#endregion
|
|
3120
3798
|
//#region src/tools/search.ts
|
|
3121
3799
|
const formatSearchResult = (result) => {
|
|
3122
3800
|
const lines = [`## [${result["result_type"]}] #${result["id"]}`];
|
|
@@ -3479,30 +4157,6 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
|
3479
4157
|
};
|
|
3480
4158
|
const createTicketTools = (ctx) => {
|
|
3481
4159
|
const { subdomain, getToken } = ctx;
|
|
3482
|
-
const attachmentSchema = z.object({
|
|
3483
|
-
file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
|
|
3484
|
-
file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
|
|
3485
|
-
abort: true,
|
|
3486
|
-
error: (issue) => `Attachment too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split the upload, or link to it instead of uploading.`
|
|
3487
|
-
}).base64().describe(`File content encoded as base64. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file), and the attachments of one call must stay under that total; the HTTP transport additionally caps request bodies at 4 MB.`),
|
|
3488
|
-
content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
|
|
3489
|
-
});
|
|
3490
|
-
const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
|
|
3491
|
-
const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
|
|
3492
|
-
if (total > 10420224) refinement.addIssue({
|
|
3493
|
-
code: "custom",
|
|
3494
|
-
message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
|
|
3495
|
-
});
|
|
3496
|
-
}).optional().describe(description);
|
|
3497
|
-
const uploadAttachments = async (token, files) => {
|
|
3498
|
-
let uploadToken;
|
|
3499
|
-
for (const file of files) {
|
|
3500
|
-
const { upload } = await zendeskUpload(subdomain, token, file.file_name, Buffer.from(file.file_base64, "base64"), file.content_type, uploadToken);
|
|
3501
|
-
uploadToken = upload.token;
|
|
3502
|
-
}
|
|
3503
|
-
return uploadToken;
|
|
3504
|
-
};
|
|
3505
|
-
const formatAttachmentSuffix = (count) => count ? ` with ${count} attachment(s)` : "";
|
|
3506
4160
|
return [
|
|
3507
4161
|
{
|
|
3508
4162
|
name: "get_ticket",
|
|
@@ -3817,7 +4471,7 @@ const createTicketTools = (ctx) => {
|
|
|
3817
4471
|
handler: async (params) => {
|
|
3818
4472
|
const { ticket_id, body, attachments } = params;
|
|
3819
4473
|
const token = await getToken();
|
|
3820
|
-
const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
|
|
4474
|
+
const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
|
|
3821
4475
|
await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
|
|
3822
4476
|
body,
|
|
3823
4477
|
public: false,
|
|
@@ -3849,7 +4503,7 @@ const createTicketTools = (ctx) => {
|
|
|
3849
4503
|
handler: async (params) => {
|
|
3850
4504
|
const { ticket_id, body, attachments } = params;
|
|
3851
4505
|
const token = await getToken();
|
|
3852
|
-
const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
|
|
4506
|
+
const uploads = attachments?.length ? [await uploadAttachments(subdomain, token, attachments)] : void 0;
|
|
3853
4507
|
await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
|
|
3854
4508
|
body,
|
|
3855
4509
|
public: true,
|
|
@@ -4282,6 +4936,7 @@ const createUserTools = (ctx) => {
|
|
|
4282
4936
|
//#region src/tools/index.ts
|
|
4283
4937
|
const createAllTools = (ctx) => [
|
|
4284
4938
|
...createTicketTools(ctx),
|
|
4939
|
+
...createRequestTools(ctx),
|
|
4285
4940
|
...createSearchTools(ctx),
|
|
4286
4941
|
...createHelpCenterTools(ctx),
|
|
4287
4942
|
...createUserTools(ctx)
|
|
@@ -4290,19 +4945,16 @@ const createAllTools = (ctx) => [
|
|
|
4290
4945
|
//#region src/utils/validation.ts
|
|
4291
4946
|
/**
|
|
4292
4947
|
* Build a strict params parser for a tool's input schema, computing the strict
|
|
4293
|
-
* schema and the valid-key list once
|
|
4294
|
-
* than per call.
|
|
4948
|
+
* schema and the valid-key list once at construction rather than per call.
|
|
4295
4949
|
*
|
|
4296
4950
|
* Zod objects default to `strip`, which silently drops unknown keys. That hid
|
|
4297
4951
|
* #100: a caller passing `per_page` to list_tickets (whose parameter is
|
|
4298
|
-
* `page_size`) had the key dropped, so
|
|
4299
|
-
*
|
|
4300
|
-
*
|
|
4301
|
-
* and lists the valid parameters so a mistyped/misremembered name fails loudly.
|
|
4952
|
+
* `page_size`) had the key dropped, so a large unpaginated page came back. The
|
|
4953
|
+
* parser rejects unknown keys and names both the offending ones and the valid
|
|
4954
|
+
* parameters, so a misremembered name fails loudly.
|
|
4302
4955
|
*
|
|
4303
|
-
* Used on the proxy dispatch path (namespace/single modes)
|
|
4304
|
-
*
|
|
4305
|
-
* register and produces its own (also explicit) "Unrecognized key" message.
|
|
4956
|
+
* Used on the proxy dispatch path (namespace/single modes); in `all` mode the
|
|
4957
|
+
* SDK validates against the strict schema we register.
|
|
4306
4958
|
*/
|
|
4307
4959
|
const createStrictParamsParser = (schema) => {
|
|
4308
4960
|
const strict = schema.strict();
|
|
@@ -4319,17 +4971,15 @@ const createStrictParamsParser = (schema) => {
|
|
|
4319
4971
|
//#region src/server.ts
|
|
4320
4972
|
/**
|
|
4321
4973
|
* Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
|
|
4322
|
-
* token (401)
|
|
4323
|
-
*
|
|
4324
|
-
*
|
|
4325
|
-
* bearer, owned by the client).
|
|
4974
|
+
* token (401), so the OAuth store drops the dead token instead of replaying it.
|
|
4975
|
+
* The callback is omitted only where there is nothing to invalidate (the HTTP
|
|
4976
|
+
* per-session bearer, owned by the client).
|
|
4326
4977
|
*
|
|
4327
|
-
*
|
|
4328
|
-
*
|
|
4329
|
-
*
|
|
4330
|
-
*
|
|
4331
|
-
*
|
|
4332
|
-
* token is revoked between the pre-call refresh check and the request.
|
|
4978
|
+
* The 401 is a *backstop*, not a transparent retry: the current call still
|
|
4979
|
+
* surfaces the error, and recovery happens on the *next* one, whose `getToken`
|
|
4980
|
+
* refreshes silently (or falls back to browser re-auth). Proactive refresh keeps
|
|
4981
|
+
* this rare — it fires only for a token revoked between the pre-call check and
|
|
4982
|
+
* the request.
|
|
4333
4983
|
*/
|
|
4334
4984
|
const runHandler = async (def, params, onUnauthorized) => {
|
|
4335
4985
|
try {
|
|
@@ -4339,20 +4989,6 @@ const runHandler = async (def, params, onUnauthorized) => {
|
|
|
4339
4989
|
throw err;
|
|
4340
4990
|
}
|
|
4341
4991
|
};
|
|
4342
|
-
const NAMESPACE_LABELS = {
|
|
4343
|
-
tickets: {
|
|
4344
|
-
toolName: "zendesk_tickets",
|
|
4345
|
-
title: "Zendesk Tickets"
|
|
4346
|
-
},
|
|
4347
|
-
help_center: {
|
|
4348
|
-
toolName: "zendesk_help_center",
|
|
4349
|
-
title: "Zendesk Help Center"
|
|
4350
|
-
},
|
|
4351
|
-
users: {
|
|
4352
|
-
toolName: "zendesk_users",
|
|
4353
|
-
title: "Zendesk Users"
|
|
4354
|
-
}
|
|
4355
|
-
};
|
|
4356
4992
|
const summarizeDescription = (description) => {
|
|
4357
4993
|
const idx = description.indexOf(". ");
|
|
4358
4994
|
if (idx === -1) return description;
|
|
@@ -4436,8 +5072,9 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
4436
5072
|
const filteredTools = filterTools(tools, {
|
|
4437
5073
|
readOnly: config.readOnly,
|
|
4438
5074
|
namespaces: config.namespaces,
|
|
4439
|
-
tools: config.tools
|
|
4440
|
-
|
|
5075
|
+
tools: config.tools,
|
|
5076
|
+
promotedArticles: config.promotedArticles
|
|
5077
|
+
});
|
|
4441
5078
|
try {
|
|
4442
5079
|
switch (config.mode) {
|
|
4443
5080
|
case "all":
|
|
@@ -4557,6 +5194,10 @@ const TOOL_MODULES = [
|
|
|
4557
5194
|
file: "tickets.ts",
|
|
4558
5195
|
factory: "createTicketTools"
|
|
4559
5196
|
},
|
|
5197
|
+
{
|
|
5198
|
+
file: "requests.ts",
|
|
5199
|
+
factory: "createRequestTools"
|
|
5200
|
+
},
|
|
4560
5201
|
{
|
|
4561
5202
|
file: "search.ts",
|
|
4562
5203
|
factory: "createSearchTools"
|
|
@@ -4689,6 +5330,70 @@ const startDevServer = async (config, getToken, logger = silentLogger, onUnautho
|
|
|
4689
5330
|
};
|
|
4690
5331
|
/* v8 ignore stop */
|
|
4691
5332
|
//#endregion
|
|
5333
|
+
//#region src/routing/print.ts
|
|
5334
|
+
const toolLine = (tool, indent) => `${indent}${tool.name}${tool.readOnly ? "" : " (write)"}`;
|
|
5335
|
+
const renderHeader = (config) => [
|
|
5336
|
+
`Mode: ${config.mode}`,
|
|
5337
|
+
`Namespaces: ${config.namespaces.join(", ")}`,
|
|
5338
|
+
`Read-only: ${config.readOnly ? "yes" : "no"}`,
|
|
5339
|
+
...config.tools?.length ? [`Tool filter: ${config.tools.join(", ")}`] : []
|
|
5340
|
+
].join(" | ");
|
|
5341
|
+
const renderAllMode = (tools) => [`${tools.length} tool(s) exposed individually:`, ...tools.map((tool) => toolLine(tool, " "))];
|
|
5342
|
+
const renderNamespaceMode = (tools) => {
|
|
5343
|
+
const grouped = groupByNamespace(tools);
|
|
5344
|
+
const lines = [`${grouped.size} proxy tool(s) exposed:`];
|
|
5345
|
+
for (const [namespace, nsTools] of grouped) {
|
|
5346
|
+
lines.push(` ${NAMESPACE_LABELS[namespace]?.toolName ?? namespace}`);
|
|
5347
|
+
lines.push(...nsTools.map((tool) => toolLine(tool, " - ")));
|
|
5348
|
+
}
|
|
5349
|
+
return lines;
|
|
5350
|
+
};
|
|
5351
|
+
const renderSingleMode = (tools) => [
|
|
5352
|
+
`1 proxy tool exposed, wrapping ${tools.length} operation(s):`,
|
|
5353
|
+
" zendesk",
|
|
5354
|
+
...tools.map((tool) => toolLine(tool, " - "))
|
|
5355
|
+
];
|
|
5356
|
+
const renderBody = (config, tools) => {
|
|
5357
|
+
switch (config.mode) {
|
|
5358
|
+
case "all": return renderAllMode(tools);
|
|
5359
|
+
case "namespace": return renderNamespaceMode(tools);
|
|
5360
|
+
case "single": return renderSingleMode(tools);
|
|
5361
|
+
default: {
|
|
5362
|
+
const unhandled = config.mode;
|
|
5363
|
+
throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
};
|
|
5367
|
+
/**
|
|
5368
|
+
* Render the tool surface `config` resolves to, as `registerToolset` would
|
|
5369
|
+
* expose it, without starting a server or issuing a request -- the combination
|
|
5370
|
+
* of `--namespace`/`--tool`, `--mode` and `--read-only` is easier read than
|
|
5371
|
+
* predicted.
|
|
5372
|
+
*
|
|
5373
|
+
* It mirrors `registerToolset`'s `switch (config.mode)` rather than calling it,
|
|
5374
|
+
* which would need a live `McpServer`; tests pin this output against the names
|
|
5375
|
+
* the integration harness sees over the wire.
|
|
5376
|
+
*
|
|
5377
|
+
* Read-only is stated in the header, not as a `[RO]` name prefix: the server
|
|
5378
|
+
* puts that marker in a proxy's description, so `[RO] zendesk_tickets` would
|
|
5379
|
+
* name a tool no client sees.
|
|
5380
|
+
*/
|
|
5381
|
+
const renderToolSurface = (config, tools) => {
|
|
5382
|
+
const filtered = filterTools(tools, {
|
|
5383
|
+
readOnly: config.readOnly,
|
|
5384
|
+
namespaces: config.namespaces,
|
|
5385
|
+
tools: config.tools,
|
|
5386
|
+
promotedArticles: config.promotedArticles
|
|
5387
|
+
});
|
|
5388
|
+
const header = renderHeader(config);
|
|
5389
|
+
if (filtered.length === 0) return `${header}\n\nNo tools exposed. Check --namespace / --tool / --read-only.`;
|
|
5390
|
+
return [
|
|
5391
|
+
header,
|
|
5392
|
+
"",
|
|
5393
|
+
...renderBody(config, filtered)
|
|
5394
|
+
].join("\n");
|
|
5395
|
+
};
|
|
5396
|
+
//#endregion
|
|
4692
5397
|
//#region src/transports/http.ts
|
|
4693
5398
|
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
4694
5399
|
"0.0.0.0",
|
|
@@ -4791,12 +5496,13 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
|
|
|
4791
5496
|
const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
|
|
4792
5497
|
const issuer = `https://${config.subdomain}.zendesk.com`;
|
|
4793
5498
|
const resource = resolveResourceUrl(config, logger);
|
|
5499
|
+
const scopes = () => supportedScopes(config.readOnly);
|
|
4794
5500
|
return {
|
|
4795
5501
|
protectedResource: {
|
|
4796
5502
|
authorization_servers: [issuer],
|
|
4797
5503
|
resource,
|
|
4798
5504
|
bearer_methods_supported: ["header"],
|
|
4799
|
-
scopes_supported:
|
|
5505
|
+
scopes_supported: scopes()
|
|
4800
5506
|
},
|
|
4801
5507
|
authorizationServer: {
|
|
4802
5508
|
issuer,
|
|
@@ -4806,7 +5512,7 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
|
|
|
4806
5512
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
4807
5513
|
code_challenge_methods_supported: ["S256"],
|
|
4808
5514
|
token_endpoint_auth_methods_supported: ["none"],
|
|
4809
|
-
scopes_supported:
|
|
5515
|
+
scopes_supported: scopes()
|
|
4810
5516
|
}
|
|
4811
5517
|
};
|
|
4812
5518
|
};
|
|
@@ -5079,16 +5785,15 @@ const defaultRuntime = createRuntime(process);
|
|
|
5079
5785
|
/**
|
|
5080
5786
|
* Install the process's one shutdown path and return its trigger.
|
|
5081
5787
|
*
|
|
5082
|
-
* Registering a `SIGTERM` handler *removes* Node's default terminate,
|
|
5083
|
-
*
|
|
5084
|
-
*
|
|
5085
|
-
*
|
|
5086
|
-
* rather than defensive, and the unconditional `exit` on every path.
|
|
5788
|
+
* Registering a `SIGTERM` handler *removes* Node's default terminate, making
|
|
5789
|
+
* the exit our responsibility: a cleanup stalled on an in-flight request would
|
|
5790
|
+
* leave a process SIGTERM cannot kill — the symptom this exists to remove.
|
|
5791
|
+
* Hence the watchdog and the unconditional `exit` on every path.
|
|
5087
5792
|
*
|
|
5088
5793
|
* The exit is explicit rather than a drained event loop because the OAuth
|
|
5089
5794
|
* callback server (`auth/browser-oauth.ts`) is a listening socket that is not
|
|
5090
|
-
* `unref()`'d:
|
|
5091
|
-
*
|
|
5795
|
+
* `unref()`'d: draining would keep a disconnected session alive for the
|
|
5796
|
+
* 5-minute auth timeout.
|
|
5092
5797
|
*/
|
|
5093
5798
|
const installShutdown = (options) => {
|
|
5094
5799
|
const { cleanup, logger, watchStdin, graceMs = SHUTDOWN_GRACE_MS } = options;
|
|
@@ -5129,7 +5834,8 @@ const installShutdown = (options) => {
|
|
|
5129
5834
|
const buildStdioTokenStore = (config, logger) => createTokenStore({
|
|
5130
5835
|
subdomain: config.subdomain,
|
|
5131
5836
|
oauthClientId: config.oauthClientId,
|
|
5132
|
-
callbackPort: config.callbackPort
|
|
5837
|
+
callbackPort: config.callbackPort,
|
|
5838
|
+
readOnly: config.readOnly
|
|
5133
5839
|
}, logger);
|
|
5134
5840
|
const connectStdio = async (config, tokenStore, logger) => {
|
|
5135
5841
|
if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
@@ -5139,6 +5845,14 @@ const connectStdio = async (config, tokenStore, logger) => {
|
|
|
5139
5845
|
};
|
|
5140
5846
|
const main = async () => {
|
|
5141
5847
|
const config = loadConfig();
|
|
5848
|
+
if (config.printTools) {
|
|
5849
|
+
const tools = createAllTools({
|
|
5850
|
+
subdomain: config.subdomain,
|
|
5851
|
+
getToken: () => ""
|
|
5852
|
+
});
|
|
5853
|
+
console.log(renderToolSurface(config, tools));
|
|
5854
|
+
return;
|
|
5855
|
+
}
|
|
5142
5856
|
const logger = createLogger(config.logLevel);
|
|
5143
5857
|
if (config.transport === "stdio") {
|
|
5144
5858
|
const tokenStore = buildStdioTokenStore(config, logger);
|