@fruggr/zendesk-mcp-server 1.1.2 → 1.1.3
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 +26 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1847 -2163
- package/dist/index.js.map +1 -1
- package/package.json +8 -11
package/dist/index.js
CHANGED
|
@@ -1,2223 +1,1907 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
var buildBasicAuthHeader = (email, apiToken) => {
|
|
5
|
-
const credentials = `${email}/token:${apiToken}`;
|
|
6
|
-
return `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
// src/auth/browser-oauth.ts
|
|
10
|
-
import { createHash, randomBytes } from "crypto";
|
|
11
|
-
import { createServer } from "http";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
12
4
|
import open from "open";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
5
|
+
import * as z from "zod/v4";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import * as cheerio from "cheerio";
|
|
8
|
+
import { toHtml } from "hast-util-to-html";
|
|
9
|
+
import rehypeParse from "rehype-parse";
|
|
10
|
+
import rehypeRaw from "rehype-raw";
|
|
11
|
+
import rehypeRemark from "rehype-remark";
|
|
12
|
+
import rehypeStringify from "rehype-stringify";
|
|
13
|
+
import remarkGfm from "remark-gfm";
|
|
14
|
+
import remarkParse from "remark-parse";
|
|
15
|
+
import remarkRehype from "remark-rehype";
|
|
16
|
+
import remarkStringify from "remark-stringify";
|
|
17
|
+
import { unified } from "unified";
|
|
18
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
+
//#region src/auth/api-token.ts
|
|
20
|
+
/**
|
|
21
|
+
* API token authentication for stdio transport.
|
|
22
|
+
* Uses Basic auth: base64(email/token:api_token)
|
|
23
|
+
*/
|
|
24
|
+
const buildBasicAuthHeader = (email, apiToken) => {
|
|
25
|
+
const credentials = `${email}/token:${apiToken}`;
|
|
26
|
+
return `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/constants.ts
|
|
30
|
+
const CHARACTER_LIMIT = 25e3;
|
|
31
|
+
const MAX_COMMENT_PAGES = Number(process.env["ZENDESK_MAX_COMMENT_PAGES"] ?? 10);
|
|
32
|
+
const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
|
|
33
|
+
const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
|
|
34
|
+
const getOAuthUrls = (subdomain) => ({
|
|
35
|
+
authorizeUrl: `https://${subdomain}.zendesk.com/oauth/authorizations/new`,
|
|
36
|
+
tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
|
|
29
37
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
reject(new Error("OAuth authentication timed out (5 min). Please try again."));
|
|
121
|
-
},
|
|
122
|
-
5 * 60 * 1e3
|
|
123
|
-
).unref();
|
|
124
|
-
});
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/auth/browser-oauth.ts
|
|
40
|
+
const DEFAULT_CALLBACK_PORT = 3e3;
|
|
41
|
+
const generateCodeVerifier = () => randomBytes(32).toString("base64url");
|
|
42
|
+
const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
43
|
+
/**
|
|
44
|
+
* Performs OAuth 2.1 PKCE flow by opening the user's browser.
|
|
45
|
+
* Starts a temporary HTTP server to receive the callback.
|
|
46
|
+
* Returns the access token on success.
|
|
47
|
+
*/
|
|
48
|
+
const authenticateViaBrowser = (config) => {
|
|
49
|
+
const { subdomain, oauthClientId } = config;
|
|
50
|
+
const { authorizeUrl, tokenUrl } = getOAuthUrls(subdomain);
|
|
51
|
+
const codeVerifier = generateCodeVerifier();
|
|
52
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let callbackServer;
|
|
55
|
+
callbackServer = createServer(async (req, res) => {
|
|
56
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
57
|
+
if (url.pathname !== "/callback") {
|
|
58
|
+
res.writeHead(404);
|
|
59
|
+
res.end("Not found");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const code = url.searchParams.get("code");
|
|
63
|
+
const error = url.searchParams.get("error");
|
|
64
|
+
if (error) {
|
|
65
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
66
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
67
|
+
res.end(`<html><body><h1>Authentication failed</h1><p>${desc}</p></body></html>`);
|
|
68
|
+
callbackServer.close();
|
|
69
|
+
reject(/* @__PURE__ */ new Error(`OAuth error: ${desc}`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!code) {
|
|
73
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
74
|
+
res.end("<html><body><h1>Missing authorization code</h1></body></html>");
|
|
75
|
+
callbackServer.close();
|
|
76
|
+
reject(/* @__PURE__ */ new Error("Missing authorization code in callback"));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const callbackPort = callbackServer.address().port;
|
|
81
|
+
const tokenBody = new URLSearchParams({
|
|
82
|
+
grant_type: "authorization_code",
|
|
83
|
+
code,
|
|
84
|
+
client_id: oauthClientId,
|
|
85
|
+
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
86
|
+
code_verifier: codeVerifier
|
|
87
|
+
});
|
|
88
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
91
|
+
body: tokenBody.toString()
|
|
92
|
+
});
|
|
93
|
+
if (!tokenResponse.ok) {
|
|
94
|
+
const errorBody = await tokenResponse.text();
|
|
95
|
+
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
96
|
+
}
|
|
97
|
+
const tokenData = await tokenResponse.json();
|
|
98
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
99
|
+
res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><script>window.close()<\/script></body></html>");
|
|
100
|
+
callbackServer.close();
|
|
101
|
+
resolve(tokenData);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
104
|
+
res.end(`<html><body><h1>Token exchange failed</h1><p>${err instanceof Error ? err.message : String(err)}</p></body></html>`);
|
|
105
|
+
callbackServer.close();
|
|
106
|
+
reject(err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
callbackServer.listen(config.callbackPort ?? DEFAULT_CALLBACK_PORT, () => {
|
|
110
|
+
const redirectUri = `http://localhost:${callbackServer.address().port}/callback`;
|
|
111
|
+
const authUrl = `${authorizeUrl}?${new URLSearchParams({
|
|
112
|
+
response_type: "code",
|
|
113
|
+
client_id: oauthClientId,
|
|
114
|
+
redirect_uri: redirectUri,
|
|
115
|
+
scope: "read write",
|
|
116
|
+
code_challenge: codeChallenge,
|
|
117
|
+
code_challenge_method: "S256"
|
|
118
|
+
}).toString()}`;
|
|
119
|
+
console.error(`Opening browser for Zendesk authentication...`);
|
|
120
|
+
console.error(`If the browser doesn't open, visit: ${authUrl}`);
|
|
121
|
+
open(authUrl).catch(() => {});
|
|
122
|
+
});
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
callbackServer.close();
|
|
125
|
+
reject(/* @__PURE__ */ new Error("OAuth authentication timed out (5 min). Please try again."));
|
|
126
|
+
}, 300 * 1e3).unref();
|
|
127
|
+
});
|
|
125
128
|
};
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/auth/token-store.ts
|
|
131
|
+
const createTokenStore = (config) => {
|
|
132
|
+
let token;
|
|
133
|
+
let authPromise;
|
|
134
|
+
const setToken = (accessToken, refreshToken) => {
|
|
135
|
+
token = {
|
|
136
|
+
accessToken,
|
|
137
|
+
refreshToken
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
const ensureToken = async () => {
|
|
141
|
+
if (token) return token;
|
|
142
|
+
if (!authPromise) authPromise = authenticateViaBrowser({
|
|
143
|
+
subdomain: config.subdomain,
|
|
144
|
+
oauthClientId: config.oauthClientId
|
|
145
|
+
}).then((result) => {
|
|
146
|
+
const stored = {
|
|
147
|
+
accessToken: result.access_token,
|
|
148
|
+
refreshToken: result.refresh_token
|
|
149
|
+
};
|
|
150
|
+
token = stored;
|
|
151
|
+
authPromise = void 0;
|
|
152
|
+
return stored;
|
|
153
|
+
}).catch((err) => {
|
|
154
|
+
authPromise = void 0;
|
|
155
|
+
throw err;
|
|
156
|
+
});
|
|
157
|
+
return authPromise;
|
|
158
|
+
};
|
|
159
|
+
const getToken = async () => {
|
|
160
|
+
return (await ensureToken()).accessToken;
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
getToken,
|
|
164
|
+
setToken
|
|
165
|
+
};
|
|
160
166
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/config.ts
|
|
169
|
+
const ToolMode = z.enum([
|
|
170
|
+
"single",
|
|
171
|
+
"namespace",
|
|
172
|
+
"all"
|
|
173
|
+
]);
|
|
174
|
+
const LogLevel = z.enum([
|
|
175
|
+
"debug",
|
|
176
|
+
"info",
|
|
177
|
+
"warn",
|
|
178
|
+
"error"
|
|
179
|
+
]);
|
|
180
|
+
const Namespace = z.enum([
|
|
181
|
+
"tickets",
|
|
182
|
+
"help_center",
|
|
183
|
+
"users"
|
|
184
|
+
]);
|
|
185
|
+
const ConfigSchema = z.object({
|
|
186
|
+
subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
|
|
187
|
+
oauthClientId: z.string().min(1),
|
|
188
|
+
zendeskEmail: z.string().optional(),
|
|
189
|
+
zendeskApiToken: z.string().optional(),
|
|
190
|
+
logLevel: LogLevel,
|
|
191
|
+
mode: ToolMode,
|
|
192
|
+
readOnly: z.boolean(),
|
|
193
|
+
namespaces: z.array(Namespace).optional(),
|
|
194
|
+
tools: z.array(z.string()).optional()
|
|
177
195
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return result;
|
|
196
|
+
const parseCliArgs = (args) => {
|
|
197
|
+
const result = {};
|
|
198
|
+
let positionalIndex = 0;
|
|
199
|
+
for (let i = 0; i < args.length; i++) {
|
|
200
|
+
const arg = args[i];
|
|
201
|
+
if (arg === void 0) continue;
|
|
202
|
+
const next = args[i + 1];
|
|
203
|
+
if (arg === "--mode" && next) {
|
|
204
|
+
result.mode = next;
|
|
205
|
+
i++;
|
|
206
|
+
} else if (arg === "--read-only") result.readOnly = true;
|
|
207
|
+
else if (arg === "--namespace" && next) {
|
|
208
|
+
result.namespaces = result.namespaces ?? [];
|
|
209
|
+
result.namespaces.push(next);
|
|
210
|
+
i++;
|
|
211
|
+
} else if (arg === "--tool" && next) {
|
|
212
|
+
result.tools = result.tools ?? [];
|
|
213
|
+
result.tools.push(next);
|
|
214
|
+
i++;
|
|
215
|
+
} else if (arg === "--log-level" && next) {
|
|
216
|
+
result.logLevel = next;
|
|
217
|
+
i++;
|
|
218
|
+
} else if (!arg.startsWith("-") && positionalIndex === 0) {
|
|
219
|
+
result.subdomain = arg;
|
|
220
|
+
positionalIndex++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
207
224
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
226
|
+
const cli = parseCliArgs(argv);
|
|
227
|
+
const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
|
|
228
|
+
const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
|
|
229
|
+
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
230
|
+
return ConfigSchema.parse({
|
|
231
|
+
subdomain,
|
|
232
|
+
oauthClientId,
|
|
233
|
+
zendeskEmail: process.env["ZENDESK_EMAIL"],
|
|
234
|
+
zendeskApiToken: process.env["ZENDESK_API_TOKEN"],
|
|
235
|
+
logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
|
|
236
|
+
mode,
|
|
237
|
+
readOnly: cli.readOnly ?? false,
|
|
238
|
+
namespaces: cli.namespaces,
|
|
239
|
+
tools: cli.tools
|
|
240
|
+
});
|
|
224
241
|
};
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (options.readOnly && !tool.readOnly) return false;
|
|
233
|
-
if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
|
|
234
|
-
if (options.tools?.length && !options.tools.includes(tool.name)) return false;
|
|
235
|
-
return true;
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/routing/registry.ts
|
|
244
|
+
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
245
|
+
if (options.readOnly && !tool.readOnly) return false;
|
|
246
|
+
if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
|
|
247
|
+
if (options.tools?.length && !options.tools.includes(tool.name)) return false;
|
|
248
|
+
return true;
|
|
236
249
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
250
|
+
const groupByNamespace = (tools) => {
|
|
251
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
252
|
+
for (const tool of tools) {
|
|
253
|
+
const existing = grouped.get(tool.namespace) ?? [];
|
|
254
|
+
existing.push(tool);
|
|
255
|
+
grouped.set(tool.namespace, existing);
|
|
256
|
+
}
|
|
257
|
+
return grouped;
|
|
245
258
|
};
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return `Resource not found. Please verify the ID is correct. (${statusText})`;
|
|
270
|
-
case 422:
|
|
271
|
-
return `Validation error: ${body}`;
|
|
272
|
-
case 429:
|
|
273
|
-
return "Rate limit exceeded. Please wait before making more requests.";
|
|
274
|
-
default:
|
|
275
|
-
return `Zendesk API error ${status}: ${statusText}. ${body}`;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/client/zendesk-api.ts
|
|
261
|
+
var ZendeskApiError = class ZendeskApiError extends Error {
|
|
262
|
+
status;
|
|
263
|
+
statusText;
|
|
264
|
+
body;
|
|
265
|
+
constructor(status, statusText, body) {
|
|
266
|
+
super(ZendeskApiError.buildMessage(status, statusText, body));
|
|
267
|
+
this.status = status;
|
|
268
|
+
this.statusText = statusText;
|
|
269
|
+
this.body = body;
|
|
270
|
+
this.name = "ZendeskApiError";
|
|
271
|
+
}
|
|
272
|
+
static buildMessage(status, statusText, body) {
|
|
273
|
+
switch (status) {
|
|
274
|
+
case 401: return "Authentication failed. Your Zendesk token may be expired or invalid. Re-authenticate to get a new token.";
|
|
275
|
+
case 403: return "Permission denied. Your Zendesk account does not have access to this resource.";
|
|
276
|
+
case 404: return `Resource not found. Please verify the ID is correct. (${statusText})`;
|
|
277
|
+
case 422: return `Validation error: ${body}`;
|
|
278
|
+
case 429: return "Rate limit exceeded. Please wait before making more requests.";
|
|
279
|
+
default: return `Zendesk API error ${status}: ${statusText}. ${body}`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
278
282
|
};
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
url.searchParams.set(key, value);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return url.toString();
|
|
283
|
+
const buildAuthHeader = (token) => token.startsWith("Basic ") ? token : `Bearer ${token}`;
|
|
284
|
+
const buildUrl = (base, path, params) => {
|
|
285
|
+
const url = new URL(`${base}${path}`);
|
|
286
|
+
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
287
|
+
return url.toString();
|
|
288
288
|
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return {};
|
|
309
|
-
}
|
|
310
|
-
return response.json();
|
|
289
|
+
const executeRequest = async (url, token, options = {}) => {
|
|
290
|
+
const { method = "GET", body } = options;
|
|
291
|
+
const headers = {
|
|
292
|
+
Authorization: buildAuthHeader(token),
|
|
293
|
+
Accept: "application/json"
|
|
294
|
+
};
|
|
295
|
+
if (body) headers["Content-Type"] = "application/json";
|
|
296
|
+
const init = {
|
|
297
|
+
method,
|
|
298
|
+
headers
|
|
299
|
+
};
|
|
300
|
+
if (body) init.body = JSON.stringify(body);
|
|
301
|
+
const response = await fetch(url, init);
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
const responseBody = await response.text();
|
|
304
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
305
|
+
}
|
|
306
|
+
if (response.status === 204) return {};
|
|
307
|
+
return response.json();
|
|
311
308
|
};
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return executeRequest(url, token);
|
|
309
|
+
const zendeskGet = (subdomain, token, path, params) => {
|
|
310
|
+
return executeRequest(buildUrl(getBaseUrl(subdomain), path, params), token);
|
|
315
311
|
};
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
312
|
+
const zendeskPost = (subdomain, token, path, body) => {
|
|
313
|
+
return executeRequest(buildUrl(getBaseUrl(subdomain), path), token, {
|
|
314
|
+
method: "POST",
|
|
315
|
+
body
|
|
316
|
+
});
|
|
319
317
|
};
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
318
|
+
const zendeskPut = (subdomain, token, path, body) => {
|
|
319
|
+
return executeRequest(buildUrl(getBaseUrl(subdomain), path), token, {
|
|
320
|
+
method: "PUT",
|
|
321
|
+
body
|
|
322
|
+
});
|
|
323
323
|
};
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return executeRequest(url, token);
|
|
324
|
+
const helpCenterGet = (subdomain, token, path, params) => {
|
|
325
|
+
return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path, params), token);
|
|
327
326
|
};
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
const helpCenterPost = (subdomain, token, path, body) => {
|
|
328
|
+
return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path), token, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
body
|
|
331
|
+
});
|
|
331
332
|
};
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
const helpCenterPut = (subdomain, token, path, body) => {
|
|
334
|
+
return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path), token, {
|
|
335
|
+
method: "PUT",
|
|
336
|
+
body
|
|
337
|
+
});
|
|
335
338
|
};
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
339
|
+
const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
|
|
340
|
+
const expectedHost = `${subdomain}.zendesk.com`;
|
|
341
|
+
const headers = {};
|
|
342
|
+
if (new URL(contentUrl).hostname === expectedHost) headers["Authorization"] = buildAuthHeader(token);
|
|
343
|
+
const response = await fetch(contentUrl, { headers });
|
|
344
|
+
if (!response.ok) {
|
|
345
|
+
const body = await response.text();
|
|
346
|
+
throw new ZendeskApiError(response.status, response.statusText, body);
|
|
347
|
+
}
|
|
348
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
349
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
350
|
+
return {
|
|
351
|
+
data: Buffer.from(arrayBuffer),
|
|
352
|
+
contentType
|
|
353
|
+
};
|
|
350
354
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
355
|
+
const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
356
|
+
const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
|
|
357
|
+
const response = await fetch(url, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { Authorization: buildAuthHeader(token) },
|
|
360
|
+
body: formData
|
|
361
|
+
});
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
const responseBody = await response.text();
|
|
364
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
365
|
+
}
|
|
366
|
+
return response.json();
|
|
363
367
|
};
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
import remarkStringify from "remark-stringify";
|
|
376
|
-
import { unified } from "unified";
|
|
377
|
-
var HEADING_LEVELS = /* @__PURE__ */ new Set(["h1", "h2", "h3"]);
|
|
378
|
-
var countWords = (text) => {
|
|
379
|
-
const trimmed = text.trim();
|
|
380
|
-
if (!trimmed) return 0;
|
|
381
|
-
return trimmed.split(/\s+/).length;
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/utils/article-sections.ts
|
|
370
|
+
const HEADING_LEVELS = new Set([
|
|
371
|
+
"h1",
|
|
372
|
+
"h2",
|
|
373
|
+
"h3"
|
|
374
|
+
]);
|
|
375
|
+
const countWords = (text) => {
|
|
376
|
+
const trimmed = text.trim();
|
|
377
|
+
if (!trimmed) return 0;
|
|
378
|
+
return trimmed.split(/\s+/).length;
|
|
382
379
|
};
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return $("div").first().text();
|
|
380
|
+
const textOf = (html) => {
|
|
381
|
+
if (!html) return "";
|
|
382
|
+
return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
|
|
387
383
|
};
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
return result;
|
|
384
|
+
const parseSections = (html) => {
|
|
385
|
+
if (!html || !html.trim()) return [];
|
|
386
|
+
const $ = cheerio.load(html, null, false);
|
|
387
|
+
const children = $.root().contents().toArray();
|
|
388
|
+
const introParts = [];
|
|
389
|
+
const sections = [];
|
|
390
|
+
let current = null;
|
|
391
|
+
for (const node of children) {
|
|
392
|
+
const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
|
|
393
|
+
if (HEADING_LEVELS.has(tagName)) {
|
|
394
|
+
const level = Number.parseInt(tagName.slice(1), 10);
|
|
395
|
+
current = {
|
|
396
|
+
heading: $(node).text().trim(),
|
|
397
|
+
headingTag: tagName,
|
|
398
|
+
level,
|
|
399
|
+
contentParts: []
|
|
400
|
+
};
|
|
401
|
+
sections.push(current);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const outer = $.html(node);
|
|
405
|
+
if (current) current.contentParts.push(outer);
|
|
406
|
+
else introParts.push(outer);
|
|
407
|
+
}
|
|
408
|
+
const result = [];
|
|
409
|
+
if (introParts.length > 0) {
|
|
410
|
+
const introHtml = introParts.join("");
|
|
411
|
+
result.push({
|
|
412
|
+
index: 0,
|
|
413
|
+
heading: "intro",
|
|
414
|
+
headingTag: "",
|
|
415
|
+
level: 0,
|
|
416
|
+
html: introHtml,
|
|
417
|
+
wordCount: countWords(textOf(introHtml))
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
for (const s of sections) {
|
|
421
|
+
const sectionHtml = s.contentParts.join("");
|
|
422
|
+
result.push({
|
|
423
|
+
index: result.length,
|
|
424
|
+
heading: s.heading,
|
|
425
|
+
headingTag: s.headingTag,
|
|
426
|
+
level: s.level,
|
|
427
|
+
html: sectionHtml,
|
|
428
|
+
wordCount: countWords(textOf(sectionHtml))
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
439
432
|
};
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const content = idx === sectionIndex ? newHtml : section.html;
|
|
449
|
-
if (section.level === 0) return content;
|
|
450
|
-
return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
|
|
451
|
-
}).join("");
|
|
433
|
+
const replaceSectionContent = (html, sectionIndex, newHtml) => {
|
|
434
|
+
const sections = parseSections(html);
|
|
435
|
+
if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
|
|
436
|
+
return sections.map((section, idx) => {
|
|
437
|
+
const content = idx === sectionIndex ? newHtml : section.html;
|
|
438
|
+
if (section.level === 0) return content;
|
|
439
|
+
return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
|
|
440
|
+
}).join("");
|
|
452
441
|
};
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
442
|
+
const keepAsHtml = (_state, node) => ({
|
|
443
|
+
type: "html",
|
|
444
|
+
value: toHtml(node)
|
|
456
445
|
});
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
446
|
+
const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
|
|
447
|
+
table: keepAsHtml,
|
|
448
|
+
pre: keepAsHtml
|
|
449
|
+
} }).use(remarkGfm).use(remarkStringify, {
|
|
450
|
+
bullet: "-",
|
|
451
|
+
emphasis: "_",
|
|
452
|
+
fences: true
|
|
453
|
+
});
|
|
454
|
+
const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
|
|
455
|
+
const htmlToMarkdown = (html) => {
|
|
456
|
+
if (!html) return "";
|
|
457
|
+
return String(htmlToMdProcessor.processSync(html));
|
|
462
458
|
};
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
459
|
+
const markdownToHtml = (markdown) => {
|
|
460
|
+
if (!markdown) return "";
|
|
461
|
+
return String(mdToHtmlProcessor.processSync(markdown));
|
|
466
462
|
};
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/utils/formatting.ts
|
|
465
|
+
const truncateIfNeeded = (text) => {
|
|
466
|
+
if (text.length <= 25e3) return text;
|
|
467
|
+
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
|
|
474
468
|
};
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
return parts.join(" | ");
|
|
469
|
+
const formatPagination = (meta) => {
|
|
470
|
+
const parts = [`Results: ${meta.count}`];
|
|
471
|
+
if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
|
|
472
|
+
return parts.join(" | ");
|
|
481
473
|
};
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
${ticket.description}` : ""
|
|
474
|
+
const formatTicket = (ticket) => [
|
|
475
|
+
`## Ticket #${ticket.id}: ${ticket.subject}`,
|
|
476
|
+
`- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
|
|
477
|
+
`- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
|
|
478
|
+
`- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
|
|
479
|
+
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
480
|
+
ticket.description ? `\n${ticket.description}` : ""
|
|
490
481
|
].filter(Boolean).join("\n");
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
lines.push("", comment.body);
|
|
501
|
-
return lines.join("\n");
|
|
482
|
+
const formatComment = (comment) => {
|
|
483
|
+
const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
|
|
484
|
+
if (comment.attachments?.length) {
|
|
485
|
+
const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
|
|
486
|
+
lines.push(`Attachments: ${summary}`);
|
|
487
|
+
}
|
|
488
|
+
lines.push("", comment.body);
|
|
489
|
+
return lines.join("\n");
|
|
502
490
|
};
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
491
|
+
const formatUser = (user) => [
|
|
492
|
+
`## ${user.name} (${user.id})`,
|
|
493
|
+
`- **Email**: ${user.email}`,
|
|
494
|
+
`- **Role**: ${user.role}`,
|
|
495
|
+
user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
|
|
496
|
+
`- **Active**: ${user.active}`,
|
|
497
|
+
user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
|
|
510
498
|
].filter(Boolean).join("\n");
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
499
|
+
const formatOrganization = (org) => [
|
|
500
|
+
`## ${org.name} (${org.id})`,
|
|
501
|
+
org.details ? `- **Details**: ${org.details}` : "",
|
|
502
|
+
org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
|
|
503
|
+
org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
|
|
516
504
|
].filter(Boolean).join("\n");
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
505
|
+
const formatArticleSummary = (article) => [
|
|
506
|
+
`## ${article.title} (${article.id})`,
|
|
507
|
+
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
508
|
+
`- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
|
|
509
|
+
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
510
|
+
`- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
|
|
523
511
|
].filter(Boolean).join("\n");
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
512
|
+
const formatArticle = (article) => [
|
|
513
|
+
formatArticleSummary(article),
|
|
514
|
+
"",
|
|
515
|
+
article.body
|
|
516
|
+
].join("\n");
|
|
517
|
+
const formatTranslationSummary = (translation) => [
|
|
518
|
+
`## Translation: ${translation.locale} (${translation.id})`,
|
|
519
|
+
`- **Title**: ${translation.title}`,
|
|
520
|
+
`- **Draft**: ${translation.draft}`,
|
|
521
|
+
`- **Updated**: ${translation.updated_at}`
|
|
530
522
|
].join("\n");
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
523
|
+
const formatTranslation = (translation) => [
|
|
524
|
+
formatTranslationSummary(translation),
|
|
525
|
+
"",
|
|
526
|
+
translation.body
|
|
527
|
+
].join("\n");
|
|
528
|
+
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
529
|
+
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
530
|
+
const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
|
|
531
|
+
const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
532
|
+
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
533
|
+
const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
|
|
534
|
+
const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
|
|
535
|
+
const formatList = (items, formatter, meta) => {
|
|
536
|
+
return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
|
|
544
537
|
};
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (cursor) {
|
|
552
|
-
params["page[after]"] = cursor;
|
|
553
|
-
}
|
|
554
|
-
return params;
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/utils/pagination.ts
|
|
540
|
+
const buildCursorParams = (pageSize, cursor) => {
|
|
541
|
+
const params = { "page[size]": String(pageSize) };
|
|
542
|
+
if (cursor) params["page[after]"] = cursor;
|
|
543
|
+
return params;
|
|
555
544
|
};
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (page && page > 1) {
|
|
561
|
-
params["page"] = String(page);
|
|
562
|
-
}
|
|
563
|
-
return params;
|
|
545
|
+
const buildOffsetParams = (perPage, page) => {
|
|
546
|
+
const params = { per_page: String(perPage) };
|
|
547
|
+
if (page && page > 1) params["page"] = String(page);
|
|
548
|
+
return params;
|
|
564
549
|
};
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
550
|
+
const extractPaginationMeta = (response) => ({
|
|
551
|
+
has_more: response.meta?.has_more ?? response.next_page != null,
|
|
552
|
+
after_cursor: response.meta?.after_cursor ?? null,
|
|
553
|
+
count: response.count ?? 0
|
|
569
554
|
});
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
555
|
+
const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
556
|
+
const count = response.count ?? 0;
|
|
557
|
+
const has_more = count > page * perPage;
|
|
558
|
+
return {
|
|
559
|
+
has_more,
|
|
560
|
+
after_cursor: has_more ? String(page + 1) : null,
|
|
561
|
+
count
|
|
562
|
+
};
|
|
578
563
|
};
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
""
|
|
590
|
-
].join("\n");
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/tools/help-center.ts
|
|
566
|
+
const largeArticleHint = (body, sectionCount) => {
|
|
567
|
+
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
568
|
+
return [
|
|
569
|
+
`> ⚠ Large article (${body.length} chars, ${sectionCount} sections).`,
|
|
570
|
+
"> For targeted edits, prefer get_article_outline + get_article_section +",
|
|
571
|
+
"> update_article_section to avoid re-sending the full body on each write.",
|
|
572
|
+
""
|
|
573
|
+
].join("\n");
|
|
591
574
|
};
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
article_id: z2.number().int().describe("Article ID"),
|
|
1228
|
-
locale: z2.string().describe('Locale of the body (e.g., "en-us", "fr")'),
|
|
1229
|
-
section_index: z2.number().int().min(0).describe("0-based index of the section (see get_article_outline)"),
|
|
1230
|
-
format: z2.enum(["html", "markdown"]).default("html").describe(
|
|
1231
|
-
'Output format. "html" (default) is round-trip safe. "markdown" is lossy on some HTML structures \u2014 use only for human review, not before update_article_section.'
|
|
1232
|
-
)
|
|
1233
|
-
}),
|
|
1234
|
-
annotations: {
|
|
1235
|
-
readOnlyHint: true,
|
|
1236
|
-
destructiveHint: false,
|
|
1237
|
-
idempotentHint: true,
|
|
1238
|
-
openWorldHint: true
|
|
1239
|
-
},
|
|
1240
|
-
handler: async (params) => {
|
|
1241
|
-
const { article_id, locale, section_index, format } = params;
|
|
1242
|
-
const token = await getToken();
|
|
1243
|
-
const { translation } = await helpCenterGet(
|
|
1244
|
-
subdomain,
|
|
1245
|
-
token,
|
|
1246
|
-
`/articles/${article_id}/translations/${locale}`
|
|
1247
|
-
);
|
|
1248
|
-
const sections = parseSections(translation.body);
|
|
1249
|
-
const section = sections[section_index];
|
|
1250
|
-
if (!section) {
|
|
1251
|
-
throw new Error(
|
|
1252
|
-
`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`
|
|
1253
|
-
);
|
|
1254
|
-
}
|
|
1255
|
-
const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
|
|
1256
|
-
const headerLine = section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`;
|
|
1257
|
-
const text = [
|
|
1258
|
-
headerLine,
|
|
1259
|
-
`_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
|
|
1260
|
-
"",
|
|
1261
|
-
content
|
|
1262
|
-
].join("\n");
|
|
1263
|
-
return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
|
|
1264
|
-
}
|
|
1265
|
-
},
|
|
1266
|
-
{
|
|
1267
|
-
name: "update_article_section",
|
|
1268
|
-
namespace: "help_center",
|
|
1269
|
-
readOnly: false,
|
|
1270
|
-
title: "Update Article Section",
|
|
1271
|
-
description: 'Replace the content of a single section of an article in a given locale, keeping the rest of the body intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. Default format="html" for fidelity. Use format="markdown" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells). The section heading is preserved and is NOT part of the replaced content.',
|
|
1272
|
-
inputSchema: z2.object({
|
|
1273
|
-
article_id: z2.number().int().describe("Article ID"),
|
|
1274
|
-
locale: z2.string().describe("Locale of the translation to update"),
|
|
1275
|
-
section_index: z2.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline)"),
|
|
1276
|
-
content: z2.string().describe(
|
|
1277
|
-
'New content for the section (heading excluded). HTML by default, Markdown if format="markdown".'
|
|
1278
|
-
),
|
|
1279
|
-
format: z2.enum(["html", "markdown"]).default("html").describe(
|
|
1280
|
-
'Input format. "html" (default) is the safe path. "markdown" is converted to HTML server-side but may introduce artifacts on complex content.'
|
|
1281
|
-
)
|
|
1282
|
-
}),
|
|
1283
|
-
annotations: {
|
|
1284
|
-
readOnlyHint: false,
|
|
1285
|
-
destructiveHint: false,
|
|
1286
|
-
idempotentHint: true,
|
|
1287
|
-
openWorldHint: true
|
|
1288
|
-
},
|
|
1289
|
-
handler: async (params) => {
|
|
1290
|
-
const { article_id, locale, section_index, content, format } = params;
|
|
1291
|
-
const token = await getToken();
|
|
1292
|
-
const { translation } = await helpCenterGet(
|
|
1293
|
-
subdomain,
|
|
1294
|
-
token,
|
|
1295
|
-
`/articles/${article_id}/translations/${locale}`
|
|
1296
|
-
);
|
|
1297
|
-
const newSectionHtml = format === "markdown" ? markdownToHtml(content) : content;
|
|
1298
|
-
const newBody = replaceSectionContent(translation.body, section_index, newSectionHtml);
|
|
1299
|
-
const { translation: updated } = await helpCenterPut(
|
|
1300
|
-
subdomain,
|
|
1301
|
-
token,
|
|
1302
|
-
`/articles/${article_id}/translations/${locale}`,
|
|
1303
|
-
{ translation: { body: newBody } }
|
|
1304
|
-
);
|
|
1305
|
-
const updatedSections = parseSections(updated.body);
|
|
1306
|
-
const updatedSection = updatedSections[section_index];
|
|
1307
|
-
const newWordCount = updatedSection?.wordCount ?? 0;
|
|
1308
|
-
const headingLabel = updatedSection?.heading ?? "(intro)";
|
|
1309
|
-
const text = [
|
|
1310
|
-
`Section [${section_index}] "${headingLabel}" updated for article #${article_id} (${locale}).`,
|
|
1311
|
-
`New word count: ${newWordCount}.`
|
|
1312
|
-
].join("\n");
|
|
1313
|
-
return { content: [{ type: "text", text }] };
|
|
1314
|
-
}
|
|
1315
|
-
},
|
|
1316
|
-
{
|
|
1317
|
-
name: "compare_translations",
|
|
1318
|
-
namespace: "help_center",
|
|
1319
|
-
readOnly: true,
|
|
1320
|
-
title: "Compare Article Translations",
|
|
1321
|
-
description: 'Compare section structure between two locales of the same article, matched by index. Returns a compact table (one row per section) with status: "ok" (both present, source/target word count ratio within 25%), "different" (word count ratio diverges by more than 25% \u2014 size signal only, NOT a semantic divergence: two locales may legitimately differ in verbosity) or "missing" (section absent in target). Useful to spot structurally stale or missing sections; do not interpret "different" as an edit regression on its own.',
|
|
1322
|
-
inputSchema: z2.object({
|
|
1323
|
-
article_id: z2.number().int().describe("Article ID"),
|
|
1324
|
-
source_locale: z2.string().describe("Source (reference) locale"),
|
|
1325
|
-
target_locale: z2.string().describe("Target locale to compare against source")
|
|
1326
|
-
}),
|
|
1327
|
-
annotations: {
|
|
1328
|
-
readOnlyHint: true,
|
|
1329
|
-
destructiveHint: false,
|
|
1330
|
-
idempotentHint: true,
|
|
1331
|
-
openWorldHint: true
|
|
1332
|
-
},
|
|
1333
|
-
handler: async (params) => {
|
|
1334
|
-
const { article_id, source_locale, target_locale } = params;
|
|
1335
|
-
const token = await getToken();
|
|
1336
|
-
const [sourceRes, targetRes] = await Promise.all([
|
|
1337
|
-
helpCenterGet(
|
|
1338
|
-
subdomain,
|
|
1339
|
-
token,
|
|
1340
|
-
`/articles/${article_id}/translations/${source_locale}`
|
|
1341
|
-
),
|
|
1342
|
-
helpCenterGet(
|
|
1343
|
-
subdomain,
|
|
1344
|
-
token,
|
|
1345
|
-
`/articles/${article_id}/translations/${target_locale}`
|
|
1346
|
-
)
|
|
1347
|
-
]);
|
|
1348
|
-
const sourceSections = parseSections(sourceRes.translation.body);
|
|
1349
|
-
const targetSections = parseSections(targetRes.translation.body);
|
|
1350
|
-
const maxLen = Math.max(sourceSections.length, targetSections.length);
|
|
1351
|
-
const rows = [];
|
|
1352
|
-
rows.push(`| Idx | Heading | Status | Source words | Target words |`);
|
|
1353
|
-
rows.push(`| --- | --- | --- | --- | --- |`);
|
|
1354
|
-
for (let i = 0; i < maxLen; i += 1) {
|
|
1355
|
-
const src = sourceSections[i];
|
|
1356
|
-
const tgt = targetSections[i];
|
|
1357
|
-
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
1358
|
-
const sourceWords = src?.wordCount ?? 0;
|
|
1359
|
-
const targetWords = tgt?.wordCount ?? 0;
|
|
1360
|
-
let status;
|
|
1361
|
-
if (!tgt) status = "missing";
|
|
1362
|
-
else if (!src) status = "different";
|
|
1363
|
-
else {
|
|
1364
|
-
const denom = Math.max(sourceWords, 1);
|
|
1365
|
-
const diffRatio = Math.abs(sourceWords - targetWords) / denom;
|
|
1366
|
-
status = diffRatio > 0.25 ? "different" : "ok";
|
|
1367
|
-
}
|
|
1368
|
-
rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
|
|
1369
|
-
}
|
|
1370
|
-
const text = [
|
|
1371
|
-
`# Translation diff \u2014 Article #${article_id} (${source_locale} \u2192 ${target_locale})`,
|
|
1372
|
-
"",
|
|
1373
|
-
...rows
|
|
1374
|
-
].join("\n");
|
|
1375
|
-
return { content: [{ type: "text", text }] };
|
|
1376
|
-
}
|
|
1377
|
-
},
|
|
1378
|
-
{
|
|
1379
|
-
name: "create_article_attachment",
|
|
1380
|
-
namespace: "help_center",
|
|
1381
|
-
readOnly: false,
|
|
1382
|
-
title: "Create Article Attachment",
|
|
1383
|
-
description: "Upload an attachment to an article. Provide file content as base64-encoded string.",
|
|
1384
|
-
inputSchema: z2.object({
|
|
1385
|
-
article_id: z2.number().int().describe("Article ID"),
|
|
1386
|
-
file_name: z2.string().min(1).describe('File name (e.g., "screenshot.png")'),
|
|
1387
|
-
file_base64: z2.string().min(1).describe("File content encoded as base64"),
|
|
1388
|
-
content_type: z2.string().default("application/octet-stream").describe('MIME type (e.g., "image/png", "application/pdf")')
|
|
1389
|
-
}),
|
|
1390
|
-
annotations: {
|
|
1391
|
-
readOnlyHint: false,
|
|
1392
|
-
destructiveHint: false,
|
|
1393
|
-
idempotentHint: false,
|
|
1394
|
-
openWorldHint: true
|
|
1395
|
-
},
|
|
1396
|
-
handler: async (params) => {
|
|
1397
|
-
const { article_id, file_name, file_base64, content_type } = params;
|
|
1398
|
-
const token = await getToken();
|
|
1399
|
-
const buffer = Buffer.from(file_base64, "base64");
|
|
1400
|
-
const blob = new Blob([buffer], { type: content_type });
|
|
1401
|
-
const formData = new FormData();
|
|
1402
|
-
formData.append("file", blob, file_name);
|
|
1403
|
-
const { article_attachment } = await helpCenterUpload(subdomain, token, `/articles/${article_id}/attachments`, formData);
|
|
1404
|
-
return {
|
|
1405
|
-
content: [
|
|
1406
|
-
{
|
|
1407
|
-
type: "text",
|
|
1408
|
-
text: `Attachment created for article #${article_id}.
|
|
1409
|
-
|
|
1410
|
-
${formatAttachment(article_attachment)}`
|
|
1411
|
-
}
|
|
1412
|
-
]
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
];
|
|
575
|
+
const createHelpCenterTools = (ctx) => {
|
|
576
|
+
const { subdomain, getToken } = ctx;
|
|
577
|
+
return [
|
|
578
|
+
{
|
|
579
|
+
name: "search_articles",
|
|
580
|
+
namespace: "help_center",
|
|
581
|
+
readOnly: true,
|
|
582
|
+
title: "Search Help Center Articles",
|
|
583
|
+
description: "Full-text search across Help Center articles (metadata only, no body). Use get_article for full content. Supports locale filtering. Returns total count.",
|
|
584
|
+
inputSchema: z.object({
|
|
585
|
+
query: z.string().min(1).describe("Search query"),
|
|
586
|
+
locale: z.string().optional().describe("Filter by locale (e.g., \"en-us\", \"fr\")"),
|
|
587
|
+
per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
|
|
588
|
+
page: z.number().int().min(1).default(1).describe("Page number")
|
|
589
|
+
}),
|
|
590
|
+
annotations: {
|
|
591
|
+
readOnlyHint: true,
|
|
592
|
+
destructiveHint: false,
|
|
593
|
+
idempotentHint: true,
|
|
594
|
+
openWorldHint: true
|
|
595
|
+
},
|
|
596
|
+
handler: async (params) => {
|
|
597
|
+
const { query, locale, per_page, page } = params;
|
|
598
|
+
const token = await getToken();
|
|
599
|
+
const p = {
|
|
600
|
+
query,
|
|
601
|
+
...buildOffsetParams(per_page, page)
|
|
602
|
+
};
|
|
603
|
+
if (locale) p["locale"] = locale;
|
|
604
|
+
const response = await helpCenterGet(subdomain, token, "/articles/search", p);
|
|
605
|
+
return { content: [{
|
|
606
|
+
type: "text",
|
|
607
|
+
text: formatList(response.results ?? [], formatArticleSummary, extractSearchPaginationMeta(response, per_page, page))
|
|
608
|
+
}] };
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "get_article",
|
|
613
|
+
namespace: "help_center",
|
|
614
|
+
readOnly: true,
|
|
615
|
+
title: "Get Help Center Article",
|
|
616
|
+
description: "Retrieve an article by ID with full body content. For large articles, prefer get_article_outline + get_article_section to save tokens. Optionally specify locale for a translated version. Returns body (HTML), metadata, source_locale, and list of available translations.",
|
|
617
|
+
inputSchema: z.object({
|
|
618
|
+
article_id: z.number().int().describe("Article ID"),
|
|
619
|
+
locale: z.string().optional().describe("Locale for translated version")
|
|
620
|
+
}),
|
|
621
|
+
annotations: {
|
|
622
|
+
readOnlyHint: true,
|
|
623
|
+
destructiveHint: false,
|
|
624
|
+
idempotentHint: true,
|
|
625
|
+
openWorldHint: true
|
|
626
|
+
},
|
|
627
|
+
handler: async (params) => {
|
|
628
|
+
const { article_id, locale } = params;
|
|
629
|
+
const token = await getToken();
|
|
630
|
+
const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`);
|
|
631
|
+
const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
|
|
632
|
+
return { content: [{
|
|
633
|
+
type: "text",
|
|
634
|
+
text: truncateIfNeeded((largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`)
|
|
635
|
+
}] };
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: "list_categories",
|
|
640
|
+
namespace: "help_center",
|
|
641
|
+
readOnly: true,
|
|
642
|
+
title: "List Help Center Categories",
|
|
643
|
+
description: "List all Help Center categories. Optionally filter by locale.",
|
|
644
|
+
inputSchema: z.object({
|
|
645
|
+
locale: z.string().optional(),
|
|
646
|
+
page_size: z.number().int().min(1).max(100).default(100),
|
|
647
|
+
cursor: z.string().optional()
|
|
648
|
+
}),
|
|
649
|
+
annotations: {
|
|
650
|
+
readOnlyHint: true,
|
|
651
|
+
destructiveHint: false,
|
|
652
|
+
idempotentHint: true,
|
|
653
|
+
openWorldHint: true
|
|
654
|
+
},
|
|
655
|
+
handler: async (params) => {
|
|
656
|
+
const { locale, page_size, cursor } = params;
|
|
657
|
+
const response = await helpCenterGet(subdomain, await getToken(), locale ? `/${locale}/categories` : "/categories", buildCursorParams(page_size, cursor));
|
|
658
|
+
return { content: [{
|
|
659
|
+
type: "text",
|
|
660
|
+
text: formatList(response.categories ?? [], formatCategory, extractPaginationMeta(response))
|
|
661
|
+
}] };
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
name: "list_sections",
|
|
666
|
+
namespace: "help_center",
|
|
667
|
+
readOnly: true,
|
|
668
|
+
title: "List Help Center Sections",
|
|
669
|
+
description: "List sections, optionally filtered by category ID and locale.",
|
|
670
|
+
inputSchema: z.object({
|
|
671
|
+
category_id: z.number().int().optional(),
|
|
672
|
+
locale: z.string().optional(),
|
|
673
|
+
page_size: z.number().int().min(1).max(100).default(100),
|
|
674
|
+
cursor: z.string().optional()
|
|
675
|
+
}),
|
|
676
|
+
annotations: {
|
|
677
|
+
readOnlyHint: true,
|
|
678
|
+
destructiveHint: false,
|
|
679
|
+
idempotentHint: true,
|
|
680
|
+
openWorldHint: true
|
|
681
|
+
},
|
|
682
|
+
handler: async (params) => {
|
|
683
|
+
const { category_id, locale, page_size, cursor } = params;
|
|
684
|
+
const response = await helpCenterGet(subdomain, await getToken(), category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections", buildCursorParams(page_size, cursor));
|
|
685
|
+
return { content: [{
|
|
686
|
+
type: "text",
|
|
687
|
+
text: formatList(response.sections ?? [], formatSection, extractPaginationMeta(response))
|
|
688
|
+
}] };
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: "list_articles",
|
|
693
|
+
namespace: "help_center",
|
|
694
|
+
readOnly: true,
|
|
695
|
+
title: "List Help Center Articles",
|
|
696
|
+
description: "List articles (metadata only, no body). Use get_article for full content. Optionally filter by section ID and locale. Supports sort_by (\"title\", \"created_at\", \"updated_at\") and include_translations: true to show available translation locales per article. Note: include_translations must be re-sent on each paginated request.",
|
|
697
|
+
inputSchema: z.object({
|
|
698
|
+
section_id: z.number().int().optional(),
|
|
699
|
+
locale: z.string().optional(),
|
|
700
|
+
page_size: z.number().int().min(1).max(100).default(100),
|
|
701
|
+
cursor: z.string().optional(),
|
|
702
|
+
sort_by: z.enum([
|
|
703
|
+
"created_at",
|
|
704
|
+
"updated_at",
|
|
705
|
+
"position",
|
|
706
|
+
"title"
|
|
707
|
+
]).default("position").describe("Sort field"),
|
|
708
|
+
sort_order: z.enum(["asc", "desc"]).default("asc").describe("Sort direction"),
|
|
709
|
+
include_translations: z.boolean().default(false).describe("Include available translation locales per article (causes 1 extra API call per article)")
|
|
710
|
+
}),
|
|
711
|
+
annotations: {
|
|
712
|
+
readOnlyHint: true,
|
|
713
|
+
destructiveHint: false,
|
|
714
|
+
idempotentHint: true,
|
|
715
|
+
openWorldHint: true
|
|
716
|
+
},
|
|
717
|
+
handler: async (params) => {
|
|
718
|
+
const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
|
|
719
|
+
const token = await getToken();
|
|
720
|
+
const response = await helpCenterGet(subdomain, token, section_id && locale ? `/${locale}/sections/${section_id}/articles` : section_id ? `/sections/${section_id}/articles` : locale ? `/${locale}/articles` : "/articles", {
|
|
721
|
+
...buildCursorParams(page_size, cursor),
|
|
722
|
+
sort_by,
|
|
723
|
+
sort_order
|
|
724
|
+
});
|
|
725
|
+
const articles = response.articles ?? [];
|
|
726
|
+
if (!include_translations) return { content: [{
|
|
727
|
+
type: "text",
|
|
728
|
+
text: formatList(articles, formatArticleSummary, extractPaginationMeta(response))
|
|
729
|
+
}] };
|
|
730
|
+
const formatted = await Promise.all(articles.map(async (article) => {
|
|
731
|
+
const { translations } = await helpCenterGet(subdomain, token, `/articles/${article.id}/translations`);
|
|
732
|
+
const locales = translations.map((t) => t.locale).join(", ");
|
|
733
|
+
return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
|
|
734
|
+
}));
|
|
735
|
+
const meta = extractPaginationMeta(response);
|
|
736
|
+
return { content: [{
|
|
737
|
+
type: "text",
|
|
738
|
+
text: truncateIfNeeded([meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n"))
|
|
739
|
+
}] };
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
name: "list_article_translations",
|
|
744
|
+
namespace: "help_center",
|
|
745
|
+
readOnly: true,
|
|
746
|
+
title: "List Article Translations",
|
|
747
|
+
description: "List all available translations for an article (metadata only, no body: locale, title, draft, updated_at). Use get_article with locale for full translated content.",
|
|
748
|
+
inputSchema: z.object({ article_id: z.number().int().describe("Article ID") }),
|
|
749
|
+
annotations: {
|
|
750
|
+
readOnlyHint: true,
|
|
751
|
+
destructiveHint: false,
|
|
752
|
+
idempotentHint: true,
|
|
753
|
+
openWorldHint: true
|
|
754
|
+
},
|
|
755
|
+
handler: async (params) => {
|
|
756
|
+
const { article_id } = params;
|
|
757
|
+
const { translations } = await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/translations`);
|
|
758
|
+
return { content: [{
|
|
759
|
+
type: "text",
|
|
760
|
+
text: formatList(translations, formatTranslationSummary)
|
|
761
|
+
}] };
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
name: "create_article_translation",
|
|
766
|
+
namespace: "help_center",
|
|
767
|
+
readOnly: false,
|
|
768
|
+
title: "Create Article Translation",
|
|
769
|
+
description: "Create a translation for an existing article in a specific locale.",
|
|
770
|
+
inputSchema: z.object({
|
|
771
|
+
article_id: z.number().int(),
|
|
772
|
+
locale: z.string().describe("Target locale (e.g., \"fr\", \"de\")"),
|
|
773
|
+
title: z.string().min(1),
|
|
774
|
+
body: z.string().min(1).describe("Translated body (HTML)"),
|
|
775
|
+
draft: z.boolean().default(false)
|
|
776
|
+
}),
|
|
777
|
+
annotations: {
|
|
778
|
+
readOnlyHint: false,
|
|
779
|
+
destructiveHint: false,
|
|
780
|
+
idempotentHint: false,
|
|
781
|
+
openWorldHint: true
|
|
782
|
+
},
|
|
783
|
+
handler: async (params) => {
|
|
784
|
+
const { article_id, locale, title, body, draft } = params;
|
|
785
|
+
const { translation } = await helpCenterPost(subdomain, await getToken(), `/articles/${article_id}/translations`, { translation: {
|
|
786
|
+
locale,
|
|
787
|
+
title,
|
|
788
|
+
body,
|
|
789
|
+
draft
|
|
790
|
+
} });
|
|
791
|
+
return { content: [{
|
|
792
|
+
type: "text",
|
|
793
|
+
text: `Translation created for article #${article_id} in "${locale}".\n\n${formatTranslation(translation)}`
|
|
794
|
+
}] };
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
name: "update_article_translation",
|
|
799
|
+
namespace: "help_center",
|
|
800
|
+
readOnly: false,
|
|
801
|
+
title: "Update Article Translation",
|
|
802
|
+
description: "Update article content (title, body) in a specific locale. For targeted edits on one or a few sections, prefer update_article_section — this tool replaces the FULL body and re-sends the entire article on each write. Use the article's source_locale (from get_article) for the default language, or another locale for translations.",
|
|
803
|
+
inputSchema: z.object({
|
|
804
|
+
article_id: z.number().int(),
|
|
805
|
+
locale: z.string(),
|
|
806
|
+
title: z.string().optional(),
|
|
807
|
+
body: z.string().optional(),
|
|
808
|
+
draft: z.boolean().optional()
|
|
809
|
+
}),
|
|
810
|
+
annotations: {
|
|
811
|
+
readOnlyHint: false,
|
|
812
|
+
destructiveHint: false,
|
|
813
|
+
idempotentHint: true,
|
|
814
|
+
openWorldHint: true
|
|
815
|
+
},
|
|
816
|
+
handler: async (params) => {
|
|
817
|
+
const { article_id, locale, ...updates } = params;
|
|
818
|
+
const { translation } = await helpCenterPut(subdomain, await getToken(), `/articles/${article_id}/translations/${locale}`, { translation: updates });
|
|
819
|
+
return { content: [{
|
|
820
|
+
type: "text",
|
|
821
|
+
text: `Translation updated for article #${article_id} in "${locale}".\n\n${formatTranslation(translation)}`
|
|
822
|
+
}] };
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
name: "list_permission_groups",
|
|
827
|
+
namespace: "help_center",
|
|
828
|
+
readOnly: true,
|
|
829
|
+
title: "List Permission Groups",
|
|
830
|
+
description: "List all Guide permission groups. Use this to find the permission_group_id required when creating articles.",
|
|
831
|
+
inputSchema: z.object({}),
|
|
832
|
+
annotations: {
|
|
833
|
+
readOnlyHint: true,
|
|
834
|
+
destructiveHint: false,
|
|
835
|
+
idempotentHint: true,
|
|
836
|
+
openWorldHint: true
|
|
837
|
+
},
|
|
838
|
+
handler: async () => {
|
|
839
|
+
return { content: [{
|
|
840
|
+
type: "text",
|
|
841
|
+
text: formatList((await zendeskGet(subdomain, await getToken(), "/guide/permission_groups")).permission_groups ?? [], formatPermissionGroup)
|
|
842
|
+
}] };
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
name: "create_article",
|
|
847
|
+
namespace: "help_center",
|
|
848
|
+
readOnly: false,
|
|
849
|
+
title: "Create Help Center Article",
|
|
850
|
+
description: "Create a new article in a section. The locale becomes the article's source_locale. Requires a permission_group_id (use list_permission_groups to find available IDs). To add content in other locales afterwards, use create_article_translation.",
|
|
851
|
+
inputSchema: z.object({
|
|
852
|
+
section_id: z.number().int(),
|
|
853
|
+
title: z.string().min(1),
|
|
854
|
+
body: z.string().min(1).describe("Article body (HTML)"),
|
|
855
|
+
permission_group_id: z.number().int().describe("Permission group ID (use list_permission_groups to find it)"),
|
|
856
|
+
user_segment_id: z.number().int().optional().describe("User segment ID for visibility (use list_user_segments to find it). Defaults to everyone."),
|
|
857
|
+
author_id: z.number().int().optional().describe("Author user ID. Defaults to the authenticated user."),
|
|
858
|
+
content_tag_ids: z.array(z.string()).optional().describe("Content tag IDs (use list_content_tags to find them)"),
|
|
859
|
+
locale: z.string().optional(),
|
|
860
|
+
draft: z.boolean().default(true),
|
|
861
|
+
promoted: z.boolean().default(false),
|
|
862
|
+
label_names: z.array(z.string()).optional().describe("Label names for search ranking (use list_labels to see existing labels)")
|
|
863
|
+
}),
|
|
864
|
+
annotations: {
|
|
865
|
+
readOnlyHint: false,
|
|
866
|
+
destructiveHint: false,
|
|
867
|
+
idempotentHint: false,
|
|
868
|
+
openWorldHint: true
|
|
869
|
+
},
|
|
870
|
+
handler: async (params) => {
|
|
871
|
+
const { section_id, ...articleData } = params;
|
|
872
|
+
const { article } = await helpCenterPost(subdomain, await getToken(), `/sections/${section_id}/articles`, { article: articleData });
|
|
873
|
+
return { content: [{
|
|
874
|
+
type: "text",
|
|
875
|
+
text: `Article #${article.id} created.\n\n${formatArticle(article)}`
|
|
876
|
+
}] };
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
name: "update_article",
|
|
881
|
+
namespace: "help_center",
|
|
882
|
+
readOnly: false,
|
|
883
|
+
title: "Update Help Center Article",
|
|
884
|
+
description: "Update article metadata only (draft, promoted, labels, tags, visibility, section, etc.). Does NOT update content (title, body) — use update_article_translation for that.",
|
|
885
|
+
inputSchema: z.object({
|
|
886
|
+
article_id: z.number().int(),
|
|
887
|
+
draft: z.boolean().optional(),
|
|
888
|
+
promoted: z.boolean().optional(),
|
|
889
|
+
label_names: z.array(z.string()).optional().describe("Label names for search ranking"),
|
|
890
|
+
content_tag_ids: z.array(z.string()).optional().describe("Content tag IDs"),
|
|
891
|
+
user_segment_id: z.number().int().optional().describe("User segment ID for visibility"),
|
|
892
|
+
author_id: z.number().int().optional().describe("Author user ID"),
|
|
893
|
+
permission_group_id: z.number().int().optional().describe("Permission group ID"),
|
|
894
|
+
section_id: z.number().int().optional()
|
|
895
|
+
}),
|
|
896
|
+
annotations: {
|
|
897
|
+
readOnlyHint: false,
|
|
898
|
+
destructiveHint: false,
|
|
899
|
+
idempotentHint: true,
|
|
900
|
+
openWorldHint: true
|
|
901
|
+
},
|
|
902
|
+
handler: async (params) => {
|
|
903
|
+
const { article_id, ...updates } = params;
|
|
904
|
+
const { article } = await helpCenterPut(subdomain, await getToken(), `/articles/${article_id}`, { article: updates });
|
|
905
|
+
return { content: [{
|
|
906
|
+
type: "text",
|
|
907
|
+
text: `Article #${article.id} updated.\n\n${formatArticle(article)}`
|
|
908
|
+
}] };
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
name: "list_content_tags",
|
|
913
|
+
namespace: "help_center",
|
|
914
|
+
readOnly: true,
|
|
915
|
+
title: "List Content Tags",
|
|
916
|
+
description: "List all Guide content tags. Content tags are visible to end users and help them find related articles.",
|
|
917
|
+
inputSchema: z.object({}),
|
|
918
|
+
annotations: {
|
|
919
|
+
readOnlyHint: true,
|
|
920
|
+
destructiveHint: false,
|
|
921
|
+
idempotentHint: true,
|
|
922
|
+
openWorldHint: true
|
|
923
|
+
},
|
|
924
|
+
handler: async () => {
|
|
925
|
+
return { content: [{
|
|
926
|
+
type: "text",
|
|
927
|
+
text: formatList((await zendeskGet(subdomain, await getToken(), "/guide/content_tags")).records ?? [], formatContentTag)
|
|
928
|
+
}] };
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: "create_content_tag",
|
|
933
|
+
namespace: "help_center",
|
|
934
|
+
readOnly: false,
|
|
935
|
+
title: "Create Content Tag",
|
|
936
|
+
description: "Create a new content tag for Guide articles.",
|
|
937
|
+
inputSchema: z.object({ name: z.string().min(1).describe("Content tag name") }),
|
|
938
|
+
annotations: {
|
|
939
|
+
readOnlyHint: false,
|
|
940
|
+
destructiveHint: false,
|
|
941
|
+
idempotentHint: false,
|
|
942
|
+
openWorldHint: true
|
|
943
|
+
},
|
|
944
|
+
handler: async (params) => {
|
|
945
|
+
const { name } = params;
|
|
946
|
+
const { record } = await zendeskPost(subdomain, await getToken(), "/guide/content_tags", { record: { name } });
|
|
947
|
+
return { content: [{
|
|
948
|
+
type: "text",
|
|
949
|
+
text: `Content tag created.\n\n${formatContentTag(record)}`
|
|
950
|
+
}] };
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
name: "list_labels",
|
|
955
|
+
namespace: "help_center",
|
|
956
|
+
readOnly: true,
|
|
957
|
+
title: "List Article Labels",
|
|
958
|
+
description: "List all article labels. Labels improve Help Center search ranking and are not visible to end users.",
|
|
959
|
+
inputSchema: z.object({}),
|
|
960
|
+
annotations: {
|
|
961
|
+
readOnlyHint: true,
|
|
962
|
+
destructiveHint: false,
|
|
963
|
+
idempotentHint: true,
|
|
964
|
+
openWorldHint: true
|
|
965
|
+
},
|
|
966
|
+
handler: async () => {
|
|
967
|
+
return { content: [{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: formatList((await helpCenterGet(subdomain, await getToken(), "/articles/labels")).labels ?? [], formatLabel)
|
|
970
|
+
}] };
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
name: "list_user_segments",
|
|
975
|
+
namespace: "help_center",
|
|
976
|
+
readOnly: true,
|
|
977
|
+
title: "List User Segments",
|
|
978
|
+
description: "List all user segments. User segments control article visibility (who can view). Use the ID when creating or updating articles.",
|
|
979
|
+
inputSchema: z.object({}),
|
|
980
|
+
annotations: {
|
|
981
|
+
readOnlyHint: true,
|
|
982
|
+
destructiveHint: false,
|
|
983
|
+
idempotentHint: true,
|
|
984
|
+
openWorldHint: true
|
|
985
|
+
},
|
|
986
|
+
handler: async () => {
|
|
987
|
+
return { content: [{
|
|
988
|
+
type: "text",
|
|
989
|
+
text: formatList((await helpCenterGet(subdomain, await getToken(), "/user_segments")).user_segments ?? [], formatUserSegment)
|
|
990
|
+
}] };
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
name: "list_article_attachments",
|
|
995
|
+
namespace: "help_center",
|
|
996
|
+
readOnly: true,
|
|
997
|
+
title: "List Article Attachments",
|
|
998
|
+
description: "List all attachments for an article.",
|
|
999
|
+
inputSchema: z.object({ article_id: z.number().int().describe("Article ID") }),
|
|
1000
|
+
annotations: {
|
|
1001
|
+
readOnlyHint: true,
|
|
1002
|
+
destructiveHint: false,
|
|
1003
|
+
idempotentHint: true,
|
|
1004
|
+
openWorldHint: true
|
|
1005
|
+
},
|
|
1006
|
+
handler: async (params) => {
|
|
1007
|
+
const { article_id } = params;
|
|
1008
|
+
return { content: [{
|
|
1009
|
+
type: "text",
|
|
1010
|
+
text: formatList((await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/attachments`)).article_attachments ?? [], formatAttachment)
|
|
1011
|
+
}] };
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
name: "get_article_outline",
|
|
1016
|
+
namespace: "help_center",
|
|
1017
|
+
readOnly: true,
|
|
1018
|
+
title: "Get Article Outline",
|
|
1019
|
+
description: "Return a compact outline of an article (list of sections delimited by h1/h2/h3, with word counts) for the given locale (defaults to source_locale). Includes available translations with their outdated status. Use get_article_section to fetch a specific section.",
|
|
1020
|
+
inputSchema: z.object({
|
|
1021
|
+
article_id: z.number().int().describe("Article ID"),
|
|
1022
|
+
locale: z.string().optional().describe("Locale of the body to outline (defaults to article source_locale)")
|
|
1023
|
+
}),
|
|
1024
|
+
annotations: {
|
|
1025
|
+
readOnlyHint: true,
|
|
1026
|
+
destructiveHint: false,
|
|
1027
|
+
idempotentHint: true,
|
|
1028
|
+
openWorldHint: true
|
|
1029
|
+
},
|
|
1030
|
+
handler: async (params) => {
|
|
1031
|
+
const { article_id, locale } = params;
|
|
1032
|
+
const token = await getToken();
|
|
1033
|
+
const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
|
|
1034
|
+
const effectiveLocale = locale ?? article.source_locale;
|
|
1035
|
+
const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${effectiveLocale}`);
|
|
1036
|
+
const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
|
|
1037
|
+
const sections = parseSections(translation.body);
|
|
1038
|
+
const outlineLines = sections.length ? sections.map((s) => `- [${s.index}] ${s.headingTag ? `${s.headingTag}: ` : ""}${s.heading} (${s.wordCount} words)`).join("\n") : "_(no sections detected)_";
|
|
1039
|
+
const translationsList = translations.map((t) => `- ${t.locale}${t.outdated ? " (outdated)" : ""}`).join("\n");
|
|
1040
|
+
return { content: [{
|
|
1041
|
+
type: "text",
|
|
1042
|
+
text: [
|
|
1043
|
+
`# Outline — Article #${article_id} (${effectiveLocale})`,
|
|
1044
|
+
`**Title**: ${translation.title}`,
|
|
1045
|
+
"",
|
|
1046
|
+
"## Sections",
|
|
1047
|
+
outlineLines,
|
|
1048
|
+
"",
|
|
1049
|
+
"## Available translations",
|
|
1050
|
+
translationsList
|
|
1051
|
+
].join("\n")
|
|
1052
|
+
}] };
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
name: "get_article_section",
|
|
1057
|
+
namespace: "help_center",
|
|
1058
|
+
readOnly: true,
|
|
1059
|
+
title: "Get Article Section",
|
|
1060
|
+
description: "Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format=\"html\" for round-trip safety. Pass format=\"markdown\" only for human review — the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section).",
|
|
1061
|
+
inputSchema: z.object({
|
|
1062
|
+
article_id: z.number().int().describe("Article ID"),
|
|
1063
|
+
locale: z.string().describe("Locale of the body (e.g., \"en-us\", \"fr\")"),
|
|
1064
|
+
section_index: z.number().int().min(0).describe("0-based index of the section (see get_article_outline)"),
|
|
1065
|
+
format: z.enum(["html", "markdown"]).default("html").describe("Output format. \"html\" (default) is round-trip safe. \"markdown\" is lossy on some HTML structures — use only for human review, not before update_article_section.")
|
|
1066
|
+
}),
|
|
1067
|
+
annotations: {
|
|
1068
|
+
readOnlyHint: true,
|
|
1069
|
+
destructiveHint: false,
|
|
1070
|
+
idempotentHint: true,
|
|
1071
|
+
openWorldHint: true
|
|
1072
|
+
},
|
|
1073
|
+
handler: async (params) => {
|
|
1074
|
+
const { article_id, locale, section_index, format } = params;
|
|
1075
|
+
const { translation } = await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/translations/${locale}`);
|
|
1076
|
+
const sections = parseSections(translation.body);
|
|
1077
|
+
const section = sections[section_index];
|
|
1078
|
+
if (!section) throw new Error(`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`);
|
|
1079
|
+
const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
|
|
1080
|
+
return { content: [{
|
|
1081
|
+
type: "text",
|
|
1082
|
+
text: truncateIfNeeded([
|
|
1083
|
+
section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
|
|
1084
|
+
`_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
|
|
1085
|
+
"",
|
|
1086
|
+
content
|
|
1087
|
+
].join("\n"))
|
|
1088
|
+
}] };
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
name: "update_article_section",
|
|
1093
|
+
namespace: "help_center",
|
|
1094
|
+
readOnly: false,
|
|
1095
|
+
title: "Update Article Section",
|
|
1096
|
+
description: "Replace the content of a single section of an article in a given locale, keeping the rest of the body intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. Default format=\"html\" for fidelity. Use format=\"markdown\" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells). The section heading is preserved and is NOT part of the replaced content.",
|
|
1097
|
+
inputSchema: z.object({
|
|
1098
|
+
article_id: z.number().int().describe("Article ID"),
|
|
1099
|
+
locale: z.string().describe("Locale of the translation to update"),
|
|
1100
|
+
section_index: z.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline)"),
|
|
1101
|
+
content: z.string().describe("New content for the section (heading excluded). HTML by default, Markdown if format=\"markdown\"."),
|
|
1102
|
+
format: z.enum(["html", "markdown"]).default("html").describe("Input format. \"html\" (default) is the safe path. \"markdown\" is converted to HTML server-side but may introduce artifacts on complex content.")
|
|
1103
|
+
}),
|
|
1104
|
+
annotations: {
|
|
1105
|
+
readOnlyHint: false,
|
|
1106
|
+
destructiveHint: false,
|
|
1107
|
+
idempotentHint: true,
|
|
1108
|
+
openWorldHint: true
|
|
1109
|
+
},
|
|
1110
|
+
handler: async (params) => {
|
|
1111
|
+
const { article_id, locale, section_index, content, format } = params;
|
|
1112
|
+
const token = await getToken();
|
|
1113
|
+
const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${locale}`);
|
|
1114
|
+
const newSectionHtml = format === "markdown" ? markdownToHtml(content) : content;
|
|
1115
|
+
const newBody = replaceSectionContent(translation.body, section_index, newSectionHtml);
|
|
1116
|
+
const { translation: updated } = await helpCenterPut(subdomain, token, `/articles/${article_id}/translations/${locale}`, { translation: { body: newBody } });
|
|
1117
|
+
const updatedSection = parseSections(updated.body)[section_index];
|
|
1118
|
+
const newWordCount = updatedSection?.wordCount ?? 0;
|
|
1119
|
+
return { content: [{
|
|
1120
|
+
type: "text",
|
|
1121
|
+
text: [`Section [${section_index}] "${updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`, `New word count: ${newWordCount}.`].join("\n")
|
|
1122
|
+
}] };
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
name: "compare_translations",
|
|
1127
|
+
namespace: "help_center",
|
|
1128
|
+
readOnly: true,
|
|
1129
|
+
title: "Compare Article Translations",
|
|
1130
|
+
description: "Compare section structure between two locales of the same article, matched by index. Returns a compact table (one row per section) with status: \"ok\" (both present, source/target word count ratio within 25%), \"different\" (word count ratio diverges by more than 25% — size signal only, NOT a semantic divergence: two locales may legitimately differ in verbosity) or \"missing\" (section absent in target). Useful to spot structurally stale or missing sections; do not interpret \"different\" as an edit regression on its own.",
|
|
1131
|
+
inputSchema: z.object({
|
|
1132
|
+
article_id: z.number().int().describe("Article ID"),
|
|
1133
|
+
source_locale: z.string().describe("Source (reference) locale"),
|
|
1134
|
+
target_locale: z.string().describe("Target locale to compare against source")
|
|
1135
|
+
}),
|
|
1136
|
+
annotations: {
|
|
1137
|
+
readOnlyHint: true,
|
|
1138
|
+
destructiveHint: false,
|
|
1139
|
+
idempotentHint: true,
|
|
1140
|
+
openWorldHint: true
|
|
1141
|
+
},
|
|
1142
|
+
handler: async (params) => {
|
|
1143
|
+
const { article_id, source_locale, target_locale } = params;
|
|
1144
|
+
const token = await getToken();
|
|
1145
|
+
const [sourceRes, targetRes] = await Promise.all([helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${source_locale}`), helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${target_locale}`)]);
|
|
1146
|
+
const sourceSections = parseSections(sourceRes.translation.body);
|
|
1147
|
+
const targetSections = parseSections(targetRes.translation.body);
|
|
1148
|
+
const maxLen = Math.max(sourceSections.length, targetSections.length);
|
|
1149
|
+
const rows = [];
|
|
1150
|
+
rows.push(`| Idx | Heading | Status | Source words | Target words |`);
|
|
1151
|
+
rows.push(`| --- | --- | --- | --- | --- |`);
|
|
1152
|
+
for (let i = 0; i < maxLen; i += 1) {
|
|
1153
|
+
const src = sourceSections[i];
|
|
1154
|
+
const tgt = targetSections[i];
|
|
1155
|
+
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
1156
|
+
const sourceWords = src?.wordCount ?? 0;
|
|
1157
|
+
const targetWords = tgt?.wordCount ?? 0;
|
|
1158
|
+
let status;
|
|
1159
|
+
if (!tgt) status = "missing";
|
|
1160
|
+
else if (!src) status = "different";
|
|
1161
|
+
else {
|
|
1162
|
+
const denom = Math.max(sourceWords, 1);
|
|
1163
|
+
status = Math.abs(sourceWords - targetWords) / denom > .25 ? "different" : "ok";
|
|
1164
|
+
}
|
|
1165
|
+
rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
|
|
1166
|
+
}
|
|
1167
|
+
return { content: [{
|
|
1168
|
+
type: "text",
|
|
1169
|
+
text: [
|
|
1170
|
+
`# Translation diff — Article #${article_id} (${source_locale} → ${target_locale})`,
|
|
1171
|
+
"",
|
|
1172
|
+
...rows
|
|
1173
|
+
].join("\n")
|
|
1174
|
+
}] };
|
|
1175
|
+
}
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
name: "create_article_attachment",
|
|
1179
|
+
namespace: "help_center",
|
|
1180
|
+
readOnly: false,
|
|
1181
|
+
title: "Create Article Attachment",
|
|
1182
|
+
description: "Upload an attachment to an article. Provide file content as base64-encoded string.",
|
|
1183
|
+
inputSchema: z.object({
|
|
1184
|
+
article_id: z.number().int().describe("Article ID"),
|
|
1185
|
+
file_name: z.string().min(1).describe("File name (e.g., \"screenshot.png\")"),
|
|
1186
|
+
file_base64: z.string().min(1).describe("File content encoded as base64"),
|
|
1187
|
+
content_type: z.string().default("application/octet-stream").describe("MIME type (e.g., \"image/png\", \"application/pdf\")")
|
|
1188
|
+
}),
|
|
1189
|
+
annotations: {
|
|
1190
|
+
readOnlyHint: false,
|
|
1191
|
+
destructiveHint: false,
|
|
1192
|
+
idempotentHint: false,
|
|
1193
|
+
openWorldHint: true
|
|
1194
|
+
},
|
|
1195
|
+
handler: async (params) => {
|
|
1196
|
+
const { article_id, file_name, file_base64, content_type } = params;
|
|
1197
|
+
const token = await getToken();
|
|
1198
|
+
const buffer = Buffer.from(file_base64, "base64");
|
|
1199
|
+
const blob = new Blob([buffer], { type: content_type });
|
|
1200
|
+
const formData = new FormData();
|
|
1201
|
+
formData.append("file", blob, file_name);
|
|
1202
|
+
const { article_attachment } = await helpCenterUpload(subdomain, token, `/articles/${article_id}/attachments`, formData);
|
|
1203
|
+
return { content: [{
|
|
1204
|
+
type: "text",
|
|
1205
|
+
text: `Attachment created for article #${article_id}.\n\n${formatAttachment(article_attachment)}`
|
|
1206
|
+
}] };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
];
|
|
1417
1210
|
};
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
return lines.join("\n");
|
|
1211
|
+
//#endregion
|
|
1212
|
+
//#region src/tools/search.ts
|
|
1213
|
+
const formatSearchResult = (result) => {
|
|
1214
|
+
const lines = [`## [${result["result_type"]}] #${result["id"]}`];
|
|
1215
|
+
if (result["subject"]) lines.push(`**Subject**: ${result["subject"]}`);
|
|
1216
|
+
if (result["name"]) lines.push(`**Name**: ${result["name"]}`);
|
|
1217
|
+
if (result["title"]) lines.push(`**Title**: ${result["title"]}`);
|
|
1218
|
+
if (result["email"]) lines.push(`**Email**: ${result["email"]}`);
|
|
1219
|
+
if (result["status"]) lines.push(`**Status**: ${result["status"]}`);
|
|
1220
|
+
if (result["description"]) {
|
|
1221
|
+
const desc = String(result["description"]);
|
|
1222
|
+
lines.push(desc.length > 200 ? `${desc.slice(0, 200)}...` : desc);
|
|
1223
|
+
}
|
|
1224
|
+
return lines.join("\n");
|
|
1433
1225
|
};
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
const meta = extractSearchPaginationMeta(response, per_page, page);
|
|
1468
|
-
const header = `Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`;
|
|
1469
|
-
const body = results.map(formatSearchResult).join("\n\n");
|
|
1470
|
-
return {
|
|
1471
|
-
content: [
|
|
1472
|
-
{ type: "text", text: truncateIfNeeded([header, body].filter(Boolean).join("\n\n")) }
|
|
1473
|
-
]
|
|
1474
|
-
};
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
];
|
|
1226
|
+
const createSearchTools = (ctx) => {
|
|
1227
|
+
const { subdomain, getToken } = ctx;
|
|
1228
|
+
return [{
|
|
1229
|
+
name: "search",
|
|
1230
|
+
namespace: "tickets",
|
|
1231
|
+
readOnly: true,
|
|
1232
|
+
title: "Zendesk Unified Search",
|
|
1233
|
+
description: "Search across tickets, users, and organizations. Supports filters like \"type:ticket status:open\", \"type:user role:agent\". Returns total count and paginated results (100 per page). Organization results include name and ID only — use get_organization for full details (tags, domains, details).",
|
|
1234
|
+
inputSchema: z.object({
|
|
1235
|
+
query: z.string().min(1).describe("Zendesk search query"),
|
|
1236
|
+
per_page: z.number().int().min(1).max(100).default(100).describe("Results per page (max 100)"),
|
|
1237
|
+
page: z.number().int().min(1).default(1).describe("Page number (1-based)")
|
|
1238
|
+
}),
|
|
1239
|
+
annotations: {
|
|
1240
|
+
readOnlyHint: true,
|
|
1241
|
+
destructiveHint: false,
|
|
1242
|
+
idempotentHint: true,
|
|
1243
|
+
openWorldHint: true
|
|
1244
|
+
},
|
|
1245
|
+
handler: async (params) => {
|
|
1246
|
+
const { query, per_page, page } = params;
|
|
1247
|
+
const response = await zendeskGet(subdomain, await getToken(), "/search", {
|
|
1248
|
+
query,
|
|
1249
|
+
...buildOffsetParams(per_page, page)
|
|
1250
|
+
});
|
|
1251
|
+
const results = response.results ?? [];
|
|
1252
|
+
const meta = extractSearchPaginationMeta(response, per_page, page);
|
|
1253
|
+
return { content: [{
|
|
1254
|
+
type: "text",
|
|
1255
|
+
text: truncateIfNeeded([`Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`, results.map(formatSearchResult).join("\n\n")].filter(Boolean).join("\n\n"))
|
|
1256
|
+
}] };
|
|
1257
|
+
}
|
|
1258
|
+
}];
|
|
1478
1259
|
};
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1260
|
+
//#endregion
|
|
1261
|
+
//#region src/tools/tickets.ts
|
|
1262
|
+
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
1263
|
+
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
1264
|
+
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
1265
|
+
return [{
|
|
1266
|
+
type: "image",
|
|
1267
|
+
data: data.toString("base64"),
|
|
1268
|
+
mimeType: contentType
|
|
1269
|
+
}, {
|
|
1270
|
+
type: "text",
|
|
1271
|
+
text: reference
|
|
1272
|
+
}];
|
|
1489
1273
|
};
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1274
|
+
const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
1275
|
+
const all = [];
|
|
1276
|
+
let cursor;
|
|
1277
|
+
let pages = 0;
|
|
1278
|
+
while (pages < MAX_COMMENT_PAGES) {
|
|
1279
|
+
const response = await zendeskGet(subdomain, token, `/tickets/${ticketId}/comments`, buildCursorParams(100, cursor));
|
|
1280
|
+
all.push(...response.comments);
|
|
1281
|
+
pages += 1;
|
|
1282
|
+
if (!response.meta?.has_more || !response.meta?.after_cursor) break;
|
|
1283
|
+
cursor = response.meta.after_cursor;
|
|
1284
|
+
}
|
|
1285
|
+
return all;
|
|
1502
1286
|
};
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1287
|
+
const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
1288
|
+
const blocks = [];
|
|
1289
|
+
let embeddedCount = 0;
|
|
1290
|
+
for (const attachment of attachments) {
|
|
1291
|
+
const reference = formatReference(attachment);
|
|
1292
|
+
if (!attachment.content_type.startsWith("image/")) {
|
|
1293
|
+
blocks.push({
|
|
1294
|
+
type: "text",
|
|
1295
|
+
text: reference
|
|
1296
|
+
});
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
let skipReason = null;
|
|
1300
|
+
if (attachment.size > 5242880) skipReason = "skipped: exceeds 5 MB per-image limit";
|
|
1301
|
+
else if (embeddedCount >= 10) skipReason = `skipped: max 10 embedded images reached`;
|
|
1302
|
+
if (skipReason) {
|
|
1303
|
+
blocks.push({
|
|
1304
|
+
type: "text",
|
|
1305
|
+
text: `${reference} — ${skipReason}`
|
|
1306
|
+
});
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
blocks.push(...await buildEmbeddedImageBlocks(subdomain, token, attachment, reference));
|
|
1311
|
+
embeddedCount += 1;
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
const reason = error instanceof ZendeskApiError ? `download failed: ${error.status} ${error.statusText}` : "download failed";
|
|
1314
|
+
blocks.push({
|
|
1315
|
+
type: "text",
|
|
1316
|
+
text: `${reference} — ${reason}`
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return blocks;
|
|
1532
1321
|
};
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}
|
|
1876
|
-
|
|
1877
|
-
${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
|
|
1878
|
-
return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
|
|
1879
|
-
}
|
|
1880
|
-
},
|
|
1881
|
-
{
|
|
1882
|
-
name: "manage_tags",
|
|
1883
|
-
namespace: "tickets",
|
|
1884
|
-
readOnly: false,
|
|
1885
|
-
title: "Manage Ticket Tags",
|
|
1886
|
-
description: "Add or remove tags on a ticket.",
|
|
1887
|
-
inputSchema: z4.object({
|
|
1888
|
-
ticket_id: z4.number().int().describe("Ticket ID"),
|
|
1889
|
-
add: z4.array(z4.string()).optional().describe("Tags to add"),
|
|
1890
|
-
remove: z4.array(z4.string()).optional().describe("Tags to remove")
|
|
1891
|
-
}),
|
|
1892
|
-
annotations: {
|
|
1893
|
-
readOnlyHint: false,
|
|
1894
|
-
destructiveHint: false,
|
|
1895
|
-
idempotentHint: true,
|
|
1896
|
-
openWorldHint: true
|
|
1897
|
-
},
|
|
1898
|
-
handler: async (params) => {
|
|
1899
|
-
const { ticket_id, add, remove } = params;
|
|
1900
|
-
const token = await getToken();
|
|
1901
|
-
const { ticket } = await zendeskGet(
|
|
1902
|
-
subdomain,
|
|
1903
|
-
token,
|
|
1904
|
-
`/tickets/${ticket_id}`
|
|
1905
|
-
);
|
|
1906
|
-
const tags = new Set(ticket.tags);
|
|
1907
|
-
add?.forEach((t) => {
|
|
1908
|
-
tags.add(t);
|
|
1909
|
-
});
|
|
1910
|
-
remove?.forEach((t) => {
|
|
1911
|
-
tags.delete(t);
|
|
1912
|
-
});
|
|
1913
|
-
const { ticket: updated } = await zendeskPut(
|
|
1914
|
-
subdomain,
|
|
1915
|
-
token,
|
|
1916
|
-
`/tickets/${ticket_id}`,
|
|
1917
|
-
{ ticket: { tags: [...tags] } }
|
|
1918
|
-
);
|
|
1919
|
-
return {
|
|
1920
|
-
content: [
|
|
1921
|
-
{
|
|
1922
|
-
type: "text",
|
|
1923
|
-
text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
|
|
1924
|
-
}
|
|
1925
|
-
]
|
|
1926
|
-
};
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
];
|
|
1322
|
+
const createTicketTools = (ctx) => {
|
|
1323
|
+
const { subdomain, getToken } = ctx;
|
|
1324
|
+
return [
|
|
1325
|
+
{
|
|
1326
|
+
name: "get_ticket",
|
|
1327
|
+
namespace: "tickets",
|
|
1328
|
+
readOnly: true,
|
|
1329
|
+
title: "Get Zendesk Ticket",
|
|
1330
|
+
description: "Retrieve a Zendesk ticket by ID, including its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes.",
|
|
1331
|
+
inputSchema: z.object({
|
|
1332
|
+
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1333
|
+
include_comments: z.boolean().default(false).describe("Include ticket comments")
|
|
1334
|
+
}),
|
|
1335
|
+
annotations: {
|
|
1336
|
+
readOnlyHint: true,
|
|
1337
|
+
destructiveHint: false,
|
|
1338
|
+
idempotentHint: true,
|
|
1339
|
+
openWorldHint: true
|
|
1340
|
+
},
|
|
1341
|
+
handler: async (params) => {
|
|
1342
|
+
const { ticket_id, include_comments } = params;
|
|
1343
|
+
const token = await getToken();
|
|
1344
|
+
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
1345
|
+
let text = formatTicket(ticket);
|
|
1346
|
+
if (include_comments) {
|
|
1347
|
+
const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`);
|
|
1348
|
+
text += `\n\n---\n# Comments\n\n${comments.map(formatComment).join("\n\n")}`;
|
|
1349
|
+
}
|
|
1350
|
+
return { content: [{
|
|
1351
|
+
type: "text",
|
|
1352
|
+
text: truncateIfNeeded(text)
|
|
1353
|
+
}] };
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
{
|
|
1357
|
+
name: "get_ticket_attachments",
|
|
1358
|
+
namespace: "tickets",
|
|
1359
|
+
readOnly: true,
|
|
1360
|
+
title: "Get Zendesk Ticket Attachments",
|
|
1361
|
+
description: "Retrieve ticket attachments. Images are embedded inline; other files are listed as text references.",
|
|
1362
|
+
inputSchema: z.object({
|
|
1363
|
+
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1364
|
+
attachment_ids: z.array(z.number().int()).optional().describe("Attachment IDs to fetch directly (e.g. extracted from a previous get_ticket(include_comments=true) call). When provided, skips the comments fetch entirely. When omitted, all attachments of the ticket are returned.")
|
|
1365
|
+
}),
|
|
1366
|
+
annotations: {
|
|
1367
|
+
readOnlyHint: true,
|
|
1368
|
+
destructiveHint: false,
|
|
1369
|
+
idempotentHint: true,
|
|
1370
|
+
openWorldHint: true
|
|
1371
|
+
},
|
|
1372
|
+
handler: async (params) => {
|
|
1373
|
+
const { ticket_id, attachment_ids } = params;
|
|
1374
|
+
const token = await getToken();
|
|
1375
|
+
let attachments;
|
|
1376
|
+
if (attachment_ids && attachment_ids.length > 0) {
|
|
1377
|
+
attachments = [];
|
|
1378
|
+
for (const id of attachment_ids) try {
|
|
1379
|
+
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
1380
|
+
attachments.push(attachment);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
1383
|
+
}
|
|
1384
|
+
} else attachments = (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
1385
|
+
if (attachments.length === 0) return { content: [{
|
|
1386
|
+
type: "text",
|
|
1387
|
+
text: `No attachments found on ticket #${ticket_id}.`
|
|
1388
|
+
}] };
|
|
1389
|
+
const blocks = await collectAttachmentBlocks(subdomain, token, attachments);
|
|
1390
|
+
return { content: [{
|
|
1391
|
+
type: "text",
|
|
1392
|
+
text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
|
|
1393
|
+
}, ...blocks] };
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
name: "search_tickets",
|
|
1398
|
+
namespace: "tickets",
|
|
1399
|
+
readOnly: true,
|
|
1400
|
+
title: "Search Zendesk Tickets",
|
|
1401
|
+
description: "Search tickets using Zendesk query syntax (e.g., \"status:open assignee:me\", \"priority:urgent type:incident\"). Returns total count.",
|
|
1402
|
+
inputSchema: z.object({
|
|
1403
|
+
query: z.string().min(1).describe("Zendesk search query string"),
|
|
1404
|
+
per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
|
|
1405
|
+
page: z.number().int().min(1).default(1).describe("Page number")
|
|
1406
|
+
}),
|
|
1407
|
+
annotations: {
|
|
1408
|
+
readOnlyHint: true,
|
|
1409
|
+
destructiveHint: false,
|
|
1410
|
+
idempotentHint: true,
|
|
1411
|
+
openWorldHint: true
|
|
1412
|
+
},
|
|
1413
|
+
handler: async (params) => {
|
|
1414
|
+
const { query, per_page, page } = params;
|
|
1415
|
+
const response = await zendeskGet(subdomain, await getToken(), "/search", {
|
|
1416
|
+
query: `type:ticket ${query}`,
|
|
1417
|
+
...buildOffsetParams(per_page, page)
|
|
1418
|
+
});
|
|
1419
|
+
return { content: [{
|
|
1420
|
+
type: "text",
|
|
1421
|
+
text: formatList(response.results ?? [], formatTicket, extractSearchPaginationMeta(response, per_page, page))
|
|
1422
|
+
}] };
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
name: "create_ticket",
|
|
1427
|
+
namespace: "tickets",
|
|
1428
|
+
readOnly: false,
|
|
1429
|
+
title: "Create Zendesk Ticket",
|
|
1430
|
+
description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags.",
|
|
1431
|
+
inputSchema: z.object({
|
|
1432
|
+
subject: z.string().min(1).describe("Ticket subject"),
|
|
1433
|
+
description: z.string().min(1).describe("Ticket description"),
|
|
1434
|
+
priority: z.enum([
|
|
1435
|
+
"urgent",
|
|
1436
|
+
"high",
|
|
1437
|
+
"normal",
|
|
1438
|
+
"low"
|
|
1439
|
+
]).optional(),
|
|
1440
|
+
type: z.enum([
|
|
1441
|
+
"problem",
|
|
1442
|
+
"incident",
|
|
1443
|
+
"question",
|
|
1444
|
+
"task"
|
|
1445
|
+
]).optional(),
|
|
1446
|
+
assignee_id: z.number().int().optional(),
|
|
1447
|
+
group_id: z.number().int().optional(),
|
|
1448
|
+
tags: z.array(z.string()).optional(),
|
|
1449
|
+
custom_fields: z.array(z.object({
|
|
1450
|
+
id: z.number().int(),
|
|
1451
|
+
value: z.unknown()
|
|
1452
|
+
})).optional()
|
|
1453
|
+
}),
|
|
1454
|
+
annotations: {
|
|
1455
|
+
readOnlyHint: false,
|
|
1456
|
+
destructiveHint: false,
|
|
1457
|
+
idempotentHint: false,
|
|
1458
|
+
openWorldHint: true
|
|
1459
|
+
},
|
|
1460
|
+
handler: async (params) => {
|
|
1461
|
+
const { subject, description, ...rest } = params;
|
|
1462
|
+
const { ticket } = await zendeskPost(subdomain, await getToken(), "/tickets", { ticket: {
|
|
1463
|
+
subject,
|
|
1464
|
+
comment: { body: description },
|
|
1465
|
+
...rest
|
|
1466
|
+
} });
|
|
1467
|
+
return { content: [{
|
|
1468
|
+
type: "text",
|
|
1469
|
+
text: `Ticket #${ticket.id} created.\n\n${formatTicket(ticket)}`
|
|
1470
|
+
}] };
|
|
1471
|
+
}
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
name: "update_ticket",
|
|
1475
|
+
namespace: "tickets",
|
|
1476
|
+
readOnly: false,
|
|
1477
|
+
title: "Update Zendesk Ticket",
|
|
1478
|
+
description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields).",
|
|
1479
|
+
inputSchema: z.object({
|
|
1480
|
+
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1481
|
+
status: z.enum([
|
|
1482
|
+
"new",
|
|
1483
|
+
"open",
|
|
1484
|
+
"pending",
|
|
1485
|
+
"hold",
|
|
1486
|
+
"solved",
|
|
1487
|
+
"closed"
|
|
1488
|
+
]).optional(),
|
|
1489
|
+
priority: z.enum([
|
|
1490
|
+
"urgent",
|
|
1491
|
+
"high",
|
|
1492
|
+
"normal",
|
|
1493
|
+
"low"
|
|
1494
|
+
]).optional(),
|
|
1495
|
+
type: z.enum([
|
|
1496
|
+
"problem",
|
|
1497
|
+
"incident",
|
|
1498
|
+
"question",
|
|
1499
|
+
"task"
|
|
1500
|
+
]).optional(),
|
|
1501
|
+
assignee_id: z.number().int().optional(),
|
|
1502
|
+
group_id: z.number().int().optional(),
|
|
1503
|
+
subject: z.string().optional(),
|
|
1504
|
+
tags: z.array(z.string()).optional(),
|
|
1505
|
+
custom_fields: z.array(z.object({
|
|
1506
|
+
id: z.number().int(),
|
|
1507
|
+
value: z.unknown()
|
|
1508
|
+
})).optional()
|
|
1509
|
+
}),
|
|
1510
|
+
annotations: {
|
|
1511
|
+
readOnlyHint: false,
|
|
1512
|
+
destructiveHint: false,
|
|
1513
|
+
idempotentHint: true,
|
|
1514
|
+
openWorldHint: true
|
|
1515
|
+
},
|
|
1516
|
+
handler: async (params) => {
|
|
1517
|
+
const { ticket_id, ...updates } = params;
|
|
1518
|
+
const { ticket } = await zendeskPut(subdomain, await getToken(), `/tickets/${ticket_id}`, { ticket: updates });
|
|
1519
|
+
return { content: [{
|
|
1520
|
+
type: "text",
|
|
1521
|
+
text: `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`
|
|
1522
|
+
}] };
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
name: "add_private_note",
|
|
1527
|
+
namespace: "tickets",
|
|
1528
|
+
readOnly: false,
|
|
1529
|
+
title: "Add Private Note",
|
|
1530
|
+
description: "Add an internal note (not visible to requester) to a ticket.",
|
|
1531
|
+
inputSchema: z.object({
|
|
1532
|
+
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1533
|
+
body: z.string().min(1).describe("Note content")
|
|
1534
|
+
}),
|
|
1535
|
+
annotations: {
|
|
1536
|
+
readOnlyHint: false,
|
|
1537
|
+
destructiveHint: false,
|
|
1538
|
+
idempotentHint: false,
|
|
1539
|
+
openWorldHint: true
|
|
1540
|
+
},
|
|
1541
|
+
handler: async (params) => {
|
|
1542
|
+
const { ticket_id, body } = params;
|
|
1543
|
+
await zendeskPut(subdomain, await getToken(), `/tickets/${ticket_id}`, { ticket: { comment: {
|
|
1544
|
+
body,
|
|
1545
|
+
public: false
|
|
1546
|
+
} } });
|
|
1547
|
+
return { content: [{
|
|
1548
|
+
type: "text",
|
|
1549
|
+
text: `Private note added to ticket #${ticket_id}.`
|
|
1550
|
+
}] };
|
|
1551
|
+
}
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
name: "add_public_comment",
|
|
1555
|
+
namespace: "tickets",
|
|
1556
|
+
readOnly: false,
|
|
1557
|
+
title: "Add Public Comment",
|
|
1558
|
+
description: "Add a public comment (visible to requester) to a ticket.",
|
|
1559
|
+
inputSchema: z.object({
|
|
1560
|
+
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1561
|
+
body: z.string().min(1).describe("Comment content")
|
|
1562
|
+
}),
|
|
1563
|
+
annotations: {
|
|
1564
|
+
readOnlyHint: false,
|
|
1565
|
+
destructiveHint: false,
|
|
1566
|
+
idempotentHint: false,
|
|
1567
|
+
openWorldHint: true
|
|
1568
|
+
},
|
|
1569
|
+
handler: async (params) => {
|
|
1570
|
+
const { ticket_id, body } = params;
|
|
1571
|
+
await zendeskPut(subdomain, await getToken(), `/tickets/${ticket_id}`, { ticket: { comment: {
|
|
1572
|
+
body,
|
|
1573
|
+
public: true
|
|
1574
|
+
} } });
|
|
1575
|
+
return { content: [{
|
|
1576
|
+
type: "text",
|
|
1577
|
+
text: `Public comment added to ticket #${ticket_id}.`
|
|
1578
|
+
}] };
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
{
|
|
1582
|
+
name: "list_tickets",
|
|
1583
|
+
namespace: "tickets",
|
|
1584
|
+
readOnly: true,
|
|
1585
|
+
title: "List Zendesk Tickets",
|
|
1586
|
+
description: "List tickets with cursor-based pagination, sorted by most recently updated.",
|
|
1587
|
+
inputSchema: z.object({
|
|
1588
|
+
page_size: z.number().int().min(1).max(100).default(100),
|
|
1589
|
+
cursor: z.string().optional().describe("Pagination cursor")
|
|
1590
|
+
}),
|
|
1591
|
+
annotations: {
|
|
1592
|
+
readOnlyHint: true,
|
|
1593
|
+
destructiveHint: false,
|
|
1594
|
+
idempotentHint: true,
|
|
1595
|
+
openWorldHint: true
|
|
1596
|
+
},
|
|
1597
|
+
handler: async (params) => {
|
|
1598
|
+
const { page_size, cursor } = params;
|
|
1599
|
+
const response = await zendeskGet(subdomain, await getToken(), "/tickets", buildCursorParams(page_size, cursor));
|
|
1600
|
+
return { content: [{
|
|
1601
|
+
type: "text",
|
|
1602
|
+
text: formatList(response.tickets ?? [], formatTicket, extractPaginationMeta(response))
|
|
1603
|
+
}] };
|
|
1604
|
+
}
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
name: "get_linked_incidents",
|
|
1608
|
+
namespace: "tickets",
|
|
1609
|
+
readOnly: true,
|
|
1610
|
+
title: "Get Linked Incidents",
|
|
1611
|
+
description: "Get all incident tickets linked to a problem ticket.",
|
|
1612
|
+
inputSchema: z.object({ problem_id: z.number().int().describe("Problem ticket ID") }),
|
|
1613
|
+
annotations: {
|
|
1614
|
+
readOnlyHint: true,
|
|
1615
|
+
destructiveHint: false,
|
|
1616
|
+
idempotentHint: true,
|
|
1617
|
+
openWorldHint: true
|
|
1618
|
+
},
|
|
1619
|
+
handler: async (params) => {
|
|
1620
|
+
const { problem_id } = params;
|
|
1621
|
+
const incidents = (await zendeskGet(subdomain, await getToken(), `/tickets/${problem_id}/incidents`)).tickets ?? [];
|
|
1622
|
+
return { content: [{
|
|
1623
|
+
type: "text",
|
|
1624
|
+
text: truncateIfNeeded(incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`)
|
|
1625
|
+
}] };
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "manage_tags",
|
|
1630
|
+
namespace: "tickets",
|
|
1631
|
+
readOnly: false,
|
|
1632
|
+
title: "Manage Ticket Tags",
|
|
1633
|
+
description: "Add or remove tags on a ticket.",
|
|
1634
|
+
inputSchema: z.object({
|
|
1635
|
+
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1636
|
+
add: z.array(z.string()).optional().describe("Tags to add"),
|
|
1637
|
+
remove: z.array(z.string()).optional().describe("Tags to remove")
|
|
1638
|
+
}),
|
|
1639
|
+
annotations: {
|
|
1640
|
+
readOnlyHint: false,
|
|
1641
|
+
destructiveHint: false,
|
|
1642
|
+
idempotentHint: true,
|
|
1643
|
+
openWorldHint: true
|
|
1644
|
+
},
|
|
1645
|
+
handler: async (params) => {
|
|
1646
|
+
const { ticket_id, add, remove } = params;
|
|
1647
|
+
const token = await getToken();
|
|
1648
|
+
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
1649
|
+
const tags = new Set(ticket.tags);
|
|
1650
|
+
add?.forEach((t) => {
|
|
1651
|
+
tags.add(t);
|
|
1652
|
+
});
|
|
1653
|
+
remove?.forEach((t) => {
|
|
1654
|
+
tags.delete(t);
|
|
1655
|
+
});
|
|
1656
|
+
const { ticket: updated } = await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { tags: [...tags] } });
|
|
1657
|
+
return { content: [{
|
|
1658
|
+
type: "text",
|
|
1659
|
+
text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
|
|
1660
|
+
}] };
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
];
|
|
1930
1664
|
};
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
}),
|
|
2057
|
-
annotations: {
|
|
2058
|
-
readOnlyHint: true,
|
|
2059
|
-
destructiveHint: false,
|
|
2060
|
-
idempotentHint: true,
|
|
2061
|
-
openWorldHint: true
|
|
2062
|
-
},
|
|
2063
|
-
handler: async (params) => {
|
|
2064
|
-
const { page_size, cursor } = params;
|
|
2065
|
-
const token = await getToken();
|
|
2066
|
-
const response = await zendeskGet(
|
|
2067
|
-
subdomain,
|
|
2068
|
-
token,
|
|
2069
|
-
"/organizations",
|
|
2070
|
-
buildCursorParams(page_size, cursor)
|
|
2071
|
-
);
|
|
2072
|
-
return {
|
|
2073
|
-
content: [
|
|
2074
|
-
{
|
|
2075
|
-
type: "text",
|
|
2076
|
-
text: formatList(
|
|
2077
|
-
response.organizations ?? [],
|
|
2078
|
-
formatOrganization,
|
|
2079
|
-
extractPaginationMeta(response)
|
|
2080
|
-
)
|
|
2081
|
-
}
|
|
2082
|
-
]
|
|
2083
|
-
};
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
];
|
|
1665
|
+
//#endregion
|
|
1666
|
+
//#region src/tools/users.ts
|
|
1667
|
+
const createUserTools = (ctx) => {
|
|
1668
|
+
const { subdomain, getToken } = ctx;
|
|
1669
|
+
return [
|
|
1670
|
+
{
|
|
1671
|
+
name: "get_current_user",
|
|
1672
|
+
namespace: "users",
|
|
1673
|
+
readOnly: true,
|
|
1674
|
+
title: "Get Current Zendesk User",
|
|
1675
|
+
description: "Get the currently authenticated Zendesk user. Useful to verify identity and permissions.",
|
|
1676
|
+
inputSchema: z.object({}),
|
|
1677
|
+
annotations: {
|
|
1678
|
+
readOnlyHint: true,
|
|
1679
|
+
destructiveHint: false,
|
|
1680
|
+
idempotentHint: true,
|
|
1681
|
+
openWorldHint: true
|
|
1682
|
+
},
|
|
1683
|
+
handler: async () => {
|
|
1684
|
+
const { user } = await zendeskGet(subdomain, await getToken(), "/users/me");
|
|
1685
|
+
return { content: [{
|
|
1686
|
+
type: "text",
|
|
1687
|
+
text: formatUser(user)
|
|
1688
|
+
}] };
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
name: "search_users",
|
|
1693
|
+
namespace: "users",
|
|
1694
|
+
readOnly: true,
|
|
1695
|
+
title: "Search Zendesk Users",
|
|
1696
|
+
description: "Search for users by name, email, or other criteria using Zendesk search query syntax. Returns total count.",
|
|
1697
|
+
inputSchema: z.object({
|
|
1698
|
+
query: z.string().min(1).describe("Search query"),
|
|
1699
|
+
per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
|
|
1700
|
+
page: z.number().int().min(1).default(1).describe("Page number")
|
|
1701
|
+
}),
|
|
1702
|
+
annotations: {
|
|
1703
|
+
readOnlyHint: true,
|
|
1704
|
+
destructiveHint: false,
|
|
1705
|
+
idempotentHint: true,
|
|
1706
|
+
openWorldHint: true
|
|
1707
|
+
},
|
|
1708
|
+
handler: async (params) => {
|
|
1709
|
+
const { query, per_page, page } = params;
|
|
1710
|
+
const response = await zendeskGet(subdomain, await getToken(), "/search", {
|
|
1711
|
+
query: `type:user ${query}`,
|
|
1712
|
+
...buildOffsetParams(per_page, page)
|
|
1713
|
+
});
|
|
1714
|
+
return { content: [{
|
|
1715
|
+
type: "text",
|
|
1716
|
+
text: formatList(response.results ?? [], formatUser, extractSearchPaginationMeta(response, per_page, page))
|
|
1717
|
+
}] };
|
|
1718
|
+
}
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
name: "get_user",
|
|
1722
|
+
namespace: "users",
|
|
1723
|
+
readOnly: true,
|
|
1724
|
+
title: "Get Zendesk User",
|
|
1725
|
+
description: "Retrieve a user by ID.",
|
|
1726
|
+
inputSchema: z.object({ user_id: z.number().int().describe("User ID") }),
|
|
1727
|
+
annotations: {
|
|
1728
|
+
readOnlyHint: true,
|
|
1729
|
+
destructiveHint: false,
|
|
1730
|
+
idempotentHint: true,
|
|
1731
|
+
openWorldHint: true
|
|
1732
|
+
},
|
|
1733
|
+
handler: async (params) => {
|
|
1734
|
+
const { user_id } = params;
|
|
1735
|
+
const { user } = await zendeskGet(subdomain, await getToken(), `/users/${user_id}`);
|
|
1736
|
+
return { content: [{
|
|
1737
|
+
type: "text",
|
|
1738
|
+
text: formatUser(user)
|
|
1739
|
+
}] };
|
|
1740
|
+
}
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
name: "get_organization",
|
|
1744
|
+
namespace: "users",
|
|
1745
|
+
readOnly: true,
|
|
1746
|
+
title: "Get Zendesk Organization",
|
|
1747
|
+
description: "Retrieve an organization by ID.",
|
|
1748
|
+
inputSchema: z.object({ organization_id: z.number().int().describe("Organization ID") }),
|
|
1749
|
+
annotations: {
|
|
1750
|
+
readOnlyHint: true,
|
|
1751
|
+
destructiveHint: false,
|
|
1752
|
+
idempotentHint: true,
|
|
1753
|
+
openWorldHint: true
|
|
1754
|
+
},
|
|
1755
|
+
handler: async (params) => {
|
|
1756
|
+
const { organization_id } = params;
|
|
1757
|
+
const { organization } = await zendeskGet(subdomain, await getToken(), `/organizations/${organization_id}`);
|
|
1758
|
+
return { content: [{
|
|
1759
|
+
type: "text",
|
|
1760
|
+
text: formatOrganization(organization)
|
|
1761
|
+
}] };
|
|
1762
|
+
}
|
|
1763
|
+
},
|
|
1764
|
+
{
|
|
1765
|
+
name: "list_organizations",
|
|
1766
|
+
namespace: "users",
|
|
1767
|
+
readOnly: true,
|
|
1768
|
+
title: "List Zendesk Organizations",
|
|
1769
|
+
description: "List all organizations with pagination.",
|
|
1770
|
+
inputSchema: z.object({
|
|
1771
|
+
page_size: z.number().int().min(1).max(100).default(100),
|
|
1772
|
+
cursor: z.string().optional()
|
|
1773
|
+
}),
|
|
1774
|
+
annotations: {
|
|
1775
|
+
readOnlyHint: true,
|
|
1776
|
+
destructiveHint: false,
|
|
1777
|
+
idempotentHint: true,
|
|
1778
|
+
openWorldHint: true
|
|
1779
|
+
},
|
|
1780
|
+
handler: async (params) => {
|
|
1781
|
+
const { page_size, cursor } = params;
|
|
1782
|
+
const response = await zendeskGet(subdomain, await getToken(), "/organizations", buildCursorParams(page_size, cursor));
|
|
1783
|
+
return { content: [{
|
|
1784
|
+
type: "text",
|
|
1785
|
+
text: formatList(response.organizations ?? [], formatOrganization, extractPaginationMeta(response))
|
|
1786
|
+
}] };
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
];
|
|
2087
1790
|
};
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
1791
|
+
//#endregion
|
|
1792
|
+
//#region src/tools/index.ts
|
|
1793
|
+
const createAllTools = (ctx) => [
|
|
1794
|
+
...createTicketTools(ctx),
|
|
1795
|
+
...createSearchTools(ctx),
|
|
1796
|
+
...createHelpCenterTools(ctx),
|
|
1797
|
+
...createUserTools(ctx)
|
|
2095
1798
|
];
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
1799
|
+
//#endregion
|
|
1800
|
+
//#region src/server.ts
|
|
1801
|
+
const NAMESPACE_LABELS = {
|
|
1802
|
+
tickets: {
|
|
1803
|
+
toolName: "zendesk_tickets",
|
|
1804
|
+
title: "Zendesk Tickets"
|
|
1805
|
+
},
|
|
1806
|
+
help_center: {
|
|
1807
|
+
toolName: "zendesk_help_center",
|
|
1808
|
+
title: "Zendesk Help Center"
|
|
1809
|
+
},
|
|
1810
|
+
users: {
|
|
1811
|
+
toolName: "zendesk_users",
|
|
1812
|
+
title: "Zendesk Users"
|
|
1813
|
+
}
|
|
2102
1814
|
};
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
1815
|
+
const summarizeDescription = (description) => {
|
|
1816
|
+
const idx = description.indexOf(". ");
|
|
1817
|
+
if (idx === -1) return description;
|
|
1818
|
+
return description.slice(0, idx + 1);
|
|
2107
1819
|
};
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
const def = handlerMap.get(operation);
|
|
2129
|
-
if (!def) {
|
|
2130
|
-
return {
|
|
2131
|
-
content: [
|
|
2132
|
-
{
|
|
2133
|
-
type: "text",
|
|
2134
|
-
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
2135
|
-
}
|
|
2136
|
-
]
|
|
2137
|
-
};
|
|
2138
|
-
}
|
|
2139
|
-
const validated = def.inputSchema.parse(params);
|
|
2140
|
-
return def.handler(validated);
|
|
2141
|
-
}
|
|
2142
|
-
);
|
|
1820
|
+
const buildOperationList = (tools) => tools.map((t) => `- **${t.name}**: ${summarizeDescription(t.description)}${t.readOnly ? "" : " (write)"}`).join("\n");
|
|
1821
|
+
const registerProxyTool = (server, toolName, title, tools, handlerMap) => {
|
|
1822
|
+
const operationNames = tools.map((t) => t.name);
|
|
1823
|
+
const operationList = buildOperationList(tools);
|
|
1824
|
+
server.registerTool(toolName, {
|
|
1825
|
+
title,
|
|
1826
|
+
description: `${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
|
|
1827
|
+
inputSchema: z.object({
|
|
1828
|
+
operation: z.string().describe(`One of: ${operationNames.join(", ")}`),
|
|
1829
|
+
params: z.record(z.string(), z.unknown()).default({}).describe("Operation parameters")
|
|
1830
|
+
})
|
|
1831
|
+
}, async ({ operation, params }) => {
|
|
1832
|
+
const def = handlerMap.get(operation);
|
|
1833
|
+
if (!def) return { content: [{
|
|
1834
|
+
type: "text",
|
|
1835
|
+
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
1836
|
+
}] };
|
|
1837
|
+
const validated = def.inputSchema.parse(params);
|
|
1838
|
+
return def.handler(validated);
|
|
1839
|
+
});
|
|
2143
1840
|
};
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
}
|
|
2183
|
-
break;
|
|
2184
|
-
}
|
|
2185
|
-
case "single": {
|
|
2186
|
-
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap);
|
|
2187
|
-
break;
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
|
-
console.error(`Registered ${filteredTools.length} tools in ${config.mode} mode`);
|
|
2191
|
-
return server;
|
|
1841
|
+
const createMcpServer = (config, getToken) => {
|
|
1842
|
+
const server = new McpServer({
|
|
1843
|
+
name: "@digital4better/zendesk-mcp-server",
|
|
1844
|
+
version: "0.1.0"
|
|
1845
|
+
});
|
|
1846
|
+
const filteredTools = filterTools(createAllTools({
|
|
1847
|
+
subdomain: config.subdomain,
|
|
1848
|
+
getToken
|
|
1849
|
+
}), {
|
|
1850
|
+
readOnly: config.readOnly,
|
|
1851
|
+
namespaces: config.namespaces,
|
|
1852
|
+
tools: config.tools
|
|
1853
|
+
});
|
|
1854
|
+
const handlerMap = /* @__PURE__ */ new Map();
|
|
1855
|
+
for (const tool of filteredTools) handlerMap.set(tool.name, tool);
|
|
1856
|
+
switch (config.mode) {
|
|
1857
|
+
case "all":
|
|
1858
|
+
for (const tool of filteredTools) server.registerTool(tool.name, {
|
|
1859
|
+
title: tool.title,
|
|
1860
|
+
description: tool.description,
|
|
1861
|
+
inputSchema: tool.inputSchema,
|
|
1862
|
+
annotations: tool.annotations
|
|
1863
|
+
}, async (params) => tool.handler(params));
|
|
1864
|
+
break;
|
|
1865
|
+
case "namespace": {
|
|
1866
|
+
const grouped = groupByNamespace(filteredTools);
|
|
1867
|
+
for (const [namespace, tools] of grouped) {
|
|
1868
|
+
const label = NAMESPACE_LABELS[namespace];
|
|
1869
|
+
if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap);
|
|
1870
|
+
}
|
|
1871
|
+
break;
|
|
1872
|
+
}
|
|
1873
|
+
case "single":
|
|
1874
|
+
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap);
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1877
|
+
console.error(`Registered ${filteredTools.length} tools in ${config.mode} mode`);
|
|
1878
|
+
return server;
|
|
2192
1879
|
};
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
console.error("Zendesk MCP server running via stdio");
|
|
1880
|
+
//#endregion
|
|
1881
|
+
//#region src/transports/stdio.ts
|
|
1882
|
+
const startStdioTransport = async (server) => {
|
|
1883
|
+
const transport = new StdioServerTransport();
|
|
1884
|
+
await server.connect(transport);
|
|
1885
|
+
console.error("Zendesk MCP server running via stdio");
|
|
2200
1886
|
};
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
oauthClientId: config.oauthClientId
|
|
2214
|
-
});
|
|
2215
|
-
const server = createMcpServer(config, tokenStore.getToken);
|
|
2216
|
-
await startStdioTransport(server);
|
|
2217
|
-
}
|
|
1887
|
+
//#endregion
|
|
1888
|
+
//#region src/index.ts
|
|
1889
|
+
const main = async () => {
|
|
1890
|
+
const config = loadConfig();
|
|
1891
|
+
if (config.zendeskEmail && config.zendeskApiToken) {
|
|
1892
|
+
const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
|
|
1893
|
+
const getToken = () => staticToken;
|
|
1894
|
+
await startStdioTransport(createMcpServer(config, getToken));
|
|
1895
|
+
} else await startStdioTransport(createMcpServer(config, createTokenStore({
|
|
1896
|
+
subdomain: config.subdomain,
|
|
1897
|
+
oauthClientId: config.oauthClientId
|
|
1898
|
+
}).getToken));
|
|
2218
1899
|
};
|
|
2219
1900
|
main().catch((error) => {
|
|
2220
|
-
|
|
2221
|
-
|
|
1901
|
+
console.error("Fatal error:", error);
|
|
1902
|
+
process.exit(1);
|
|
2222
1903
|
});
|
|
1904
|
+
//#endregion
|
|
1905
|
+
export {};
|
|
1906
|
+
|
|
2223
1907
|
//# sourceMappingURL=index.js.map
|