@fruggr/zendesk-mcp-server 1.3.0 → 1.4.1
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 +87 -2
- package/dist/index.js +227 -34
- package/dist/index.js.map +1 -1
- package/package.json +22 -8
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Zendesk MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://glama.ai/mcp/servers/fruggr/zendesk-mcp-server)
|
|
4
|
+
[](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](https://renovatebot.com)
|
|
8
|
+
[](https://github.com/semantic-release/semantic-release)
|
|
4
9
|
|
|
5
10
|
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that connects LLMs to the **Zendesk Support & Help Center APIs** — with per-user OAuth 2.1 PKCE authentication and fine-grained tool visibility controls.
|
|
6
11
|
|
|
@@ -16,6 +21,21 @@ Most Zendesk integrations use a shared admin API key, giving every user full acc
|
|
|
16
21
|
|
|
17
22
|
> Built and maintained by [Digital4better](https://digital4better.com) for the [Fruggr](https://www.fruggr.io) project.
|
|
18
23
|
|
|
24
|
+
## When to use this server
|
|
25
|
+
|
|
26
|
+
**Reach for it when:**
|
|
27
|
+
|
|
28
|
+
- You want an LLM to read or triage **Zendesk tickets** and **Help Center articles** on behalf of a real user, with that user's own permissions — not a shared admin key.
|
|
29
|
+
- You're editing **large Help Center articles** and want section-scoped reads/rewrites instead of round-tripping the full HTML body through the model.
|
|
30
|
+
- You need to **cap the tool surface** — read-only assistants, a single namespace, or one unified tool to fit a tight context budget.
|
|
31
|
+
- You run a **stdio MCP client** (Claude Desktop, Claude Code, Cursor, VS Code, Cline, …) and want a `npx`-installable server with no extra infrastructure.
|
|
32
|
+
|
|
33
|
+
**Look elsewhere when:**
|
|
34
|
+
|
|
35
|
+
- You need Zendesk products outside Support & Guide (e.g. Talk, Explore analytics, Sell) — those endpoints aren't covered.
|
|
36
|
+
- You want a hosted/remote HTTP server: this one speaks stdio and runs next to the client.
|
|
37
|
+
- You need a single shared service account for all users — that's the opposite of this server's per-user OAuth model (use API-token auth if you must, but one identity then applies to everyone).
|
|
38
|
+
|
|
19
39
|
## Tool modes
|
|
20
40
|
|
|
21
41
|
The server registers tools in one of three modes, controlled by `--mode`:
|
|
@@ -291,10 +311,38 @@ zendesk-mcp-server acme --tool get_ticket --tool search_tickets --tool get_curre
|
|
|
291
311
|
| `ZENDESK_OAUTH_CLIENT_ID` | no | `<subdomain>_zendesk` | OAuth client identifier |
|
|
292
312
|
| `ZENDESK_EMAIL` | for API token auth | — | Agent email for Basic auth |
|
|
293
313
|
| `ZENDESK_API_TOKEN` | for API token auth | — | Zendesk API token |
|
|
294
|
-
| `LOG_LEVEL` | no | `info` | Log verbosity |
|
|
314
|
+
| `LOG_LEVEL` | no | `info` | Log verbosity (`debug` surfaces the full OAuth flow trace) |
|
|
295
315
|
|
|
296
316
|
If both `ZENDESK_EMAIL` and `ZENDESK_API_TOKEN` are set, the server uses API token auth. Otherwise, it uses OAuth 2.1 PKCE.
|
|
297
317
|
|
|
318
|
+
## Troubleshooting
|
|
319
|
+
|
|
320
|
+
### The browser doesn't open during OAuth login
|
|
321
|
+
|
|
322
|
+
The OAuth flow opens your default browser on the first tool call. If it doesn't
|
|
323
|
+
open (common in sandboxed or remote desktop environments), the authorization URL
|
|
324
|
+
is still printed to the server's stderr — open it manually.
|
|
325
|
+
|
|
326
|
+
To collect diagnostics, restart with `LOG_LEVEL=debug`. The server then emits
|
|
327
|
+
structured logs through **two channels**, so they're reachable on any MCP client:
|
|
328
|
+
|
|
329
|
+
- **stderr** — captured to a log file by every mainstream client.
|
|
330
|
+
- **MCP logging notifications** (`notifications/message`) — surfaced by clients
|
|
331
|
+
that support the `logging` capability.
|
|
332
|
+
|
|
333
|
+
When the browser fails to open, look for the `oauth_browser_open_failed` event:
|
|
334
|
+
it reports the underlying error, the platform, and which environment markers are
|
|
335
|
+
present (no secrets, tokens, or env values are ever logged).
|
|
336
|
+
|
|
337
|
+
Where each client writes the server's stderr:
|
|
338
|
+
|
|
339
|
+
| Client | Log location |
|
|
340
|
+
|--------|--------------|
|
|
341
|
+
| Claude Desktop (macOS) | `~/Library/Logs/Claude/mcp-server-*.log` |
|
|
342
|
+
| Claude Desktop (Windows) | `%APPDATA%\Claude\logs\mcp-server-*.log` |
|
|
343
|
+
| Claude Code | `claude --debug`, or the session logs |
|
|
344
|
+
| Cursor / VS Code / Cline | the extension's MCP output/log panel |
|
|
345
|
+
|
|
298
346
|
## Development
|
|
299
347
|
|
|
300
348
|
### Toolchain
|
|
@@ -348,6 +396,43 @@ Versions follow [SemVer](https://semver.org/) and are calculated **automatically
|
|
|
348
396
|
| `feat!:`, `fix!:`, or a `BREAKING CHANGE:` footer | major |
|
|
349
397
|
| `docs:`, `chore:`, `refactor:`, `test:`, `ci:`, `style:`, `build:` | no release |
|
|
350
398
|
|
|
399
|
+
## FAQ
|
|
400
|
+
|
|
401
|
+
**Do I need a Zendesk admin API key?**
|
|
402
|
+
No. The default OAuth 2.1 PKCE flow means each user authenticates with their own
|
|
403
|
+
credentials and the server acts with exactly their permissions. API-token auth is
|
|
404
|
+
available for headless/CI use (see [Authentication](#authentication)).
|
|
405
|
+
|
|
406
|
+
**Which Zendesk products are supported?**
|
|
407
|
+
Zendesk Support (tickets, users, organizations) and the Help Center / Guide
|
|
408
|
+
(articles, sections, categories, translations, labels, content tags, segments,
|
|
409
|
+
attachments). Talk, Explore, and Sell are out of scope.
|
|
410
|
+
|
|
411
|
+
**How do I keep the model's context small?**
|
|
412
|
+
Use `--mode single` (one `zendesk` tool) or `--mode namespace` (three proxies),
|
|
413
|
+
and `--read-only` to drop write operations. For big articles, the section-based
|
|
414
|
+
tools (`get_article_outline`, `get_article_section`, `update_article_section`)
|
|
415
|
+
let the model touch one section at a time instead of the whole HTML body.
|
|
416
|
+
|
|
417
|
+
**Can I restrict it to read-only?**
|
|
418
|
+
Yes — pass `--read-only` and every write tool is filtered out before the proxies
|
|
419
|
+
are built, in any mode.
|
|
420
|
+
|
|
421
|
+
**Which Node.js version do I need?**
|
|
422
|
+
Node.js >= 20 to run the published package (`engines.node`). The dev toolchain
|
|
423
|
+
uses a newer Node — see [Development](#development).
|
|
424
|
+
|
|
425
|
+
**The OAuth browser window didn't open. What now?**
|
|
426
|
+
The authorization URL is also printed to stderr — open it manually. Restart with
|
|
427
|
+
`LOG_LEVEL=debug` for the full flow trace. See
|
|
428
|
+
[Troubleshooting](#troubleshooting).
|
|
429
|
+
|
|
430
|
+
**Is it safe to run via `npx`?**
|
|
431
|
+
Releases are published from CI via npm Trusted Publishing (OIDC), so each version
|
|
432
|
+
carries a build provenance attestation you can verify on its
|
|
433
|
+
[npm page](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server). No secrets
|
|
434
|
+
are ever logged by the server.
|
|
435
|
+
|
|
351
436
|
## Contributing
|
|
352
437
|
|
|
353
438
|
Pull requests are welcome — including AI-assisted ones, as long as the human author has read and validated every line.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { release } from "node:os";
|
|
4
6
|
import open from "open";
|
|
5
7
|
import * as z from "zod/v4";
|
|
6
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -15,6 +17,8 @@ import remarkParse from "remark-parse";
|
|
|
15
17
|
import remarkRehype from "remark-rehype";
|
|
16
18
|
import remarkStringify from "remark-stringify";
|
|
17
19
|
import { unified } from "unified";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
18
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
23
|
//#region src/auth/api-token.ts
|
|
20
24
|
/**
|
|
@@ -26,6 +30,91 @@ const buildBasicAuthHeader = (email, apiToken) => {
|
|
|
26
30
|
return `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
27
31
|
};
|
|
28
32
|
//#endregion
|
|
33
|
+
//#region src/utils/logger.ts
|
|
34
|
+
const SEVERITY = {
|
|
35
|
+
debug: 0,
|
|
36
|
+
info: 1,
|
|
37
|
+
warn: 2,
|
|
38
|
+
error: 3
|
|
39
|
+
};
|
|
40
|
+
const MCP_LEVEL = {
|
|
41
|
+
debug: "debug",
|
|
42
|
+
info: "info",
|
|
43
|
+
warn: "warning",
|
|
44
|
+
error: "error"
|
|
45
|
+
};
|
|
46
|
+
const REDACTED_KEYS = new Set([
|
|
47
|
+
"token",
|
|
48
|
+
"accesstoken",
|
|
49
|
+
"refreshtoken",
|
|
50
|
+
"code",
|
|
51
|
+
"codeverifier",
|
|
52
|
+
"authorization",
|
|
53
|
+
"password",
|
|
54
|
+
"secret",
|
|
55
|
+
"apitoken",
|
|
56
|
+
"bearer"
|
|
57
|
+
]);
|
|
58
|
+
const isSensitive = (key) => REDACTED_KEYS.has(key.toLowerCase().replace(/[_-]/g, ""));
|
|
59
|
+
const redactValue = (value) => {
|
|
60
|
+
if (Array.isArray(value)) return value.map(redactValue);
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [key, val] of Object.entries(value)) out[key] = isSensitive(key) ? "[REDACTED]" : redactValue(val);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
};
|
|
68
|
+
const renderValue = (value) => {
|
|
69
|
+
if (typeof value === "string") return value;
|
|
70
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
} catch {
|
|
74
|
+
return "[unserializable]";
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const formatLine = (level, event, fields) => {
|
|
78
|
+
const parts = Object.entries(fields).map(([key, value]) => `${key}=${renderValue(value)}`);
|
|
79
|
+
return `[zendesk-mcp] [${level}] ${event}${parts.length ? ` ${parts.join(" ")}` : ""}`;
|
|
80
|
+
};
|
|
81
|
+
const noop = () => {};
|
|
82
|
+
const silentLogger = {
|
|
83
|
+
debug: noop,
|
|
84
|
+
info: noop,
|
|
85
|
+
warn: noop,
|
|
86
|
+
error: noop,
|
|
87
|
+
attachServer: noop
|
|
88
|
+
};
|
|
89
|
+
const createLogger = (level) => {
|
|
90
|
+
const min = SEVERITY[level];
|
|
91
|
+
let server;
|
|
92
|
+
const emit = (lvl, event, fields) => {
|
|
93
|
+
if (SEVERITY[lvl] < min) return;
|
|
94
|
+
const safe = fields ? redactValue(fields) : {};
|
|
95
|
+
console.error(formatLine(lvl, event, safe));
|
|
96
|
+
if (server) try {
|
|
97
|
+
server.sendLoggingMessage({
|
|
98
|
+
level: MCP_LEVEL[lvl],
|
|
99
|
+
logger: "zendesk-mcp-server",
|
|
100
|
+
data: {
|
|
101
|
+
...safe,
|
|
102
|
+
event
|
|
103
|
+
}
|
|
104
|
+
}).catch(noop);
|
|
105
|
+
} catch {}
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
debug: (event, fields) => emit("debug", event, fields),
|
|
109
|
+
info: (event, fields) => emit("info", event, fields),
|
|
110
|
+
warn: (event, fields) => emit("warn", event, fields),
|
|
111
|
+
error: (event, fields) => emit("error", event, fields),
|
|
112
|
+
attachServer: (s) => {
|
|
113
|
+
server = s;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
29
118
|
//#region src/constants.ts
|
|
30
119
|
const CHARACTER_LIMIT = 25e3;
|
|
31
120
|
const MAX_COMMENT_PAGES = Number(process.env["ZENDESK_MAX_COMMENT_PAGES"] ?? 10);
|
|
@@ -38,6 +127,23 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
38
127
|
//#endregion
|
|
39
128
|
//#region src/auth/browser-oauth.ts
|
|
40
129
|
const DEFAULT_CALLBACK_PORT = 3e3;
|
|
130
|
+
const AUTH_TIMEOUT_MS = 300 * 1e3;
|
|
131
|
+
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
132
|
+
const detectWsl = () => {
|
|
133
|
+
if (process.platform !== "linux") return false;
|
|
134
|
+
try {
|
|
135
|
+
return readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft");
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Escape a string for safe interpolation into HTML text/attribute context.
|
|
142
|
+
* The local callback server echoes attacker-controllable values (the OAuth
|
|
143
|
+
* `error_description` query param, token-exchange error bodies) back into the
|
|
144
|
+
* browser response; without escaping these are a reflected-XSS sink.
|
|
145
|
+
*/
|
|
146
|
+
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
41
147
|
const generateCodeVerifier = () => randomBytes(32).toString("base64url");
|
|
42
148
|
const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
43
149
|
/**
|
|
@@ -45,13 +151,14 @@ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier
|
|
|
45
151
|
* Starts a temporary HTTP server to receive the callback.
|
|
46
152
|
* Returns the access token on success.
|
|
47
153
|
*/
|
|
48
|
-
const authenticateViaBrowser = (config) => {
|
|
154
|
+
const authenticateViaBrowser = (config, logger = silentLogger) => {
|
|
49
155
|
const { subdomain, oauthClientId } = config;
|
|
50
156
|
const { authorizeUrl, tokenUrl } = getOAuthUrls(subdomain);
|
|
51
157
|
const codeVerifier = generateCodeVerifier();
|
|
52
158
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
53
159
|
return new Promise((resolve, reject) => {
|
|
54
160
|
let callbackServer;
|
|
161
|
+
let authTimeout;
|
|
55
162
|
callbackServer = createServer(async (req, res) => {
|
|
56
163
|
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
57
164
|
if (url.pathname !== "/callback") {
|
|
@@ -61,10 +168,15 @@ const authenticateViaBrowser = (config) => {
|
|
|
61
168
|
}
|
|
62
169
|
const code = url.searchParams.get("code");
|
|
63
170
|
const error = url.searchParams.get("error");
|
|
171
|
+
logger.debug("oauth_callback_received", {
|
|
172
|
+
hasCode: Boolean(code),
|
|
173
|
+
hasError: Boolean(error)
|
|
174
|
+
});
|
|
64
175
|
if (error) {
|
|
65
176
|
const desc = url.searchParams.get("error_description") ?? error;
|
|
66
177
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
67
|
-
res.end(`<html><body><h1>Authentication failed</h1><p>${desc}</p></body></html>`);
|
|
178
|
+
res.end(`<html><body><h1>Authentication failed</h1><p>${escapeHtml(desc)}</p></body></html>`);
|
|
179
|
+
clearTimeout(authTimeout);
|
|
68
180
|
callbackServer.close();
|
|
69
181
|
reject(/* @__PURE__ */ new Error(`OAuth error: ${desc}`));
|
|
70
182
|
return;
|
|
@@ -72,6 +184,7 @@ const authenticateViaBrowser = (config) => {
|
|
|
72
184
|
if (!code) {
|
|
73
185
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
74
186
|
res.end("<html><body><h1>Missing authorization code</h1></body></html>");
|
|
187
|
+
clearTimeout(authTimeout);
|
|
75
188
|
callbackServer.close();
|
|
76
189
|
reject(/* @__PURE__ */ new Error("Missing authorization code in callback"));
|
|
77
190
|
return;
|
|
@@ -90,24 +203,29 @@ const authenticateViaBrowser = (config) => {
|
|
|
90
203
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
91
204
|
body: tokenBody.toString()
|
|
92
205
|
});
|
|
206
|
+
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
93
207
|
if (!tokenResponse.ok) {
|
|
94
208
|
const errorBody = await tokenResponse.text();
|
|
95
209
|
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
96
210
|
}
|
|
97
211
|
const tokenData = await tokenResponse.json();
|
|
212
|
+
logger.info("oauth_authenticated");
|
|
98
213
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
99
214
|
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>");
|
|
215
|
+
clearTimeout(authTimeout);
|
|
100
216
|
callbackServer.close();
|
|
101
217
|
resolve(tokenData);
|
|
102
218
|
} catch (err) {
|
|
103
219
|
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>`);
|
|
220
|
+
res.end(`<html><body><h1>Token exchange failed</h1><p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p></body></html>`);
|
|
221
|
+
clearTimeout(authTimeout);
|
|
105
222
|
callbackServer.close();
|
|
106
223
|
reject(err);
|
|
107
224
|
}
|
|
108
225
|
});
|
|
109
226
|
callbackServer.listen(config.callbackPort ?? DEFAULT_CALLBACK_PORT, () => {
|
|
110
|
-
const
|
|
227
|
+
const port = callbackServer.address().port;
|
|
228
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
111
229
|
const authUrl = `${authorizeUrl}?${new URLSearchParams({
|
|
112
230
|
response_type: "code",
|
|
113
231
|
client_id: oauthClientId,
|
|
@@ -116,19 +234,42 @@ const authenticateViaBrowser = (config) => {
|
|
|
116
234
|
code_challenge: codeChallenge,
|
|
117
235
|
code_challenge_method: "S256"
|
|
118
236
|
}).toString()}`;
|
|
237
|
+
logger.debug("oauth_callback_listening", {
|
|
238
|
+
port,
|
|
239
|
+
redirectUri
|
|
240
|
+
});
|
|
241
|
+
logger.info("oauth_browser_opening");
|
|
242
|
+
logger.debug("oauth_authorize_url", { url: authUrl });
|
|
119
243
|
console.error(`Opening browser for Zendesk authentication...`);
|
|
120
244
|
console.error(`If the browser doesn't open, visit: ${authUrl}`);
|
|
121
|
-
open(authUrl).
|
|
245
|
+
open(authUrl).then(() => {
|
|
246
|
+
logger.debug("oauth_browser_opened");
|
|
247
|
+
}).catch((err) => {
|
|
248
|
+
logger.error("oauth_browser_open_failed", {
|
|
249
|
+
error: err instanceof Error ? err.message : String(err),
|
|
250
|
+
errorCode: err?.code,
|
|
251
|
+
platform: process.platform,
|
|
252
|
+
release: release(),
|
|
253
|
+
isWsl: detectWsl(),
|
|
254
|
+
hasSystemRoot: Boolean(process.env["SYSTEMROOT"]),
|
|
255
|
+
hasWindir: Boolean(process.env["WINDIR"]),
|
|
256
|
+
hasComSpec: Boolean(process.env["ComSpec"]),
|
|
257
|
+
hasPath: Boolean(process.env["PATH"]),
|
|
258
|
+
hasDisplay: Boolean(process.env["DISPLAY"])
|
|
259
|
+
});
|
|
260
|
+
});
|
|
122
261
|
});
|
|
123
|
-
setTimeout(() => {
|
|
262
|
+
authTimeout = setTimeout(() => {
|
|
263
|
+
logger.error("oauth_timeout", { timeoutMs: AUTH_TIMEOUT_MS });
|
|
124
264
|
callbackServer.close();
|
|
125
265
|
reject(/* @__PURE__ */ new Error("OAuth authentication timed out (5 min). Please try again."));
|
|
126
|
-
},
|
|
266
|
+
}, AUTH_TIMEOUT_MS);
|
|
267
|
+
authTimeout.unref();
|
|
127
268
|
});
|
|
128
269
|
};
|
|
129
270
|
//#endregion
|
|
130
271
|
//#region src/auth/token-store.ts
|
|
131
|
-
const createTokenStore = (config) => {
|
|
272
|
+
const createTokenStore = (config, logger = silentLogger) => {
|
|
132
273
|
let token;
|
|
133
274
|
let authPromise;
|
|
134
275
|
const setToken = (accessToken, refreshToken) => {
|
|
@@ -138,22 +279,29 @@ const createTokenStore = (config) => {
|
|
|
138
279
|
};
|
|
139
280
|
};
|
|
140
281
|
const ensureToken = async () => {
|
|
141
|
-
if (token)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
282
|
+
if (token) {
|
|
283
|
+
logger.debug("oauth_token_cache_hit");
|
|
284
|
+
return token;
|
|
285
|
+
}
|
|
286
|
+
if (!authPromise) {
|
|
287
|
+
logger.info("oauth_auth_start");
|
|
288
|
+
authPromise = authenticateViaBrowser({
|
|
289
|
+
subdomain: config.subdomain,
|
|
290
|
+
oauthClientId: config.oauthClientId
|
|
291
|
+
}, logger).then((result) => {
|
|
292
|
+
const stored = {
|
|
293
|
+
accessToken: result.access_token,
|
|
294
|
+
refreshToken: result.refresh_token
|
|
295
|
+
};
|
|
296
|
+
token = stored;
|
|
297
|
+
authPromise = void 0;
|
|
298
|
+
return stored;
|
|
299
|
+
}).catch((err) => {
|
|
300
|
+
authPromise = void 0;
|
|
301
|
+
logger.warn("oauth_auth_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
302
|
+
throw err;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
157
305
|
return authPromise;
|
|
158
306
|
};
|
|
159
307
|
const getToken = async () => {
|
|
@@ -382,7 +530,7 @@ const textOf = (html) => {
|
|
|
382
530
|
return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
|
|
383
531
|
};
|
|
384
532
|
const parseSections = (html) => {
|
|
385
|
-
if (!html
|
|
533
|
+
if (!html?.trim()) return [];
|
|
386
534
|
const $ = cheerio.load(html, null, false);
|
|
387
535
|
const children = $.root().contents().toArray();
|
|
388
536
|
const introParts = [];
|
|
@@ -1799,6 +1947,45 @@ const createAllTools = (ctx) => [
|
|
|
1799
1947
|
...createUserTools(ctx)
|
|
1800
1948
|
];
|
|
1801
1949
|
//#endregion
|
|
1950
|
+
//#region src/utils/package-info.ts
|
|
1951
|
+
const FALLBACK = {
|
|
1952
|
+
name: "@fruggr/zendesk-mcp-server",
|
|
1953
|
+
version: "0.0.0"
|
|
1954
|
+
};
|
|
1955
|
+
/**
|
|
1956
|
+
* Read `name`/`version` from the package's own package.json at runtime instead
|
|
1957
|
+
* of hardcoding them. Walks up from this module to the nearest package.json,
|
|
1958
|
+
* which resolves correctly both when bundled (`dist/index.js` → repo root) and
|
|
1959
|
+
* from source/tests (`src/` has no package.json, so the root is found). Reading
|
|
1960
|
+
* at runtime (not inlining at build) matters because semantic-release bumps the
|
|
1961
|
+
* version into package.json before publishing, after the build step.
|
|
1962
|
+
*/
|
|
1963
|
+
let cached;
|
|
1964
|
+
const readPackageInfo = () => {
|
|
1965
|
+
if (cached) return cached;
|
|
1966
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1967
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
1968
|
+
try {
|
|
1969
|
+
const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
1970
|
+
if (raw && typeof raw === "object") {
|
|
1971
|
+
const pkg = raw;
|
|
1972
|
+
if (typeof pkg.name === "string" && typeof pkg.version === "string") {
|
|
1973
|
+
cached = {
|
|
1974
|
+
name: pkg.name,
|
|
1975
|
+
version: pkg.version
|
|
1976
|
+
};
|
|
1977
|
+
return cached;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
} catch {}
|
|
1981
|
+
const parent = dirname(dir);
|
|
1982
|
+
if (parent === dir) break;
|
|
1983
|
+
dir = parent;
|
|
1984
|
+
}
|
|
1985
|
+
cached = FALLBACK;
|
|
1986
|
+
return cached;
|
|
1987
|
+
};
|
|
1988
|
+
//#endregion
|
|
1802
1989
|
//#region src/server.ts
|
|
1803
1990
|
const NAMESPACE_LABELS = {
|
|
1804
1991
|
tickets: {
|
|
@@ -1840,11 +2027,13 @@ const registerProxyTool = (server, toolName, title, tools, handlerMap) => {
|
|
|
1840
2027
|
return def.handler(validated);
|
|
1841
2028
|
});
|
|
1842
2029
|
};
|
|
1843
|
-
const createMcpServer = (config, getToken) => {
|
|
2030
|
+
const createMcpServer = (config, getToken, logger = silentLogger) => {
|
|
2031
|
+
const pkg = readPackageInfo();
|
|
1844
2032
|
const server = new McpServer({
|
|
1845
|
-
name:
|
|
1846
|
-
version:
|
|
1847
|
-
});
|
|
2033
|
+
name: pkg.name,
|
|
2034
|
+
version: pkg.version
|
|
2035
|
+
}, { capabilities: { logging: {} } });
|
|
2036
|
+
logger.attachServer(server);
|
|
1848
2037
|
const filteredTools = filterTools(createAllTools({
|
|
1849
2038
|
subdomain: config.subdomain,
|
|
1850
2039
|
getToken
|
|
@@ -1876,28 +2065,32 @@ const createMcpServer = (config, getToken) => {
|
|
|
1876
2065
|
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap);
|
|
1877
2066
|
break;
|
|
1878
2067
|
}
|
|
1879
|
-
|
|
2068
|
+
logger.info("tools_registered", {
|
|
2069
|
+
count: filteredTools.length,
|
|
2070
|
+
mode: config.mode
|
|
2071
|
+
});
|
|
1880
2072
|
return server;
|
|
1881
2073
|
};
|
|
1882
2074
|
//#endregion
|
|
1883
2075
|
//#region src/transports/stdio.ts
|
|
1884
|
-
const startStdioTransport = async (server) => {
|
|
2076
|
+
const startStdioTransport = async (server, logger = silentLogger) => {
|
|
1885
2077
|
const transport = new StdioServerTransport();
|
|
1886
2078
|
await server.connect(transport);
|
|
1887
|
-
|
|
2079
|
+
logger.info("stdio_transport_ready");
|
|
1888
2080
|
};
|
|
1889
2081
|
//#endregion
|
|
1890
2082
|
//#region src/index.ts
|
|
1891
2083
|
const main = async () => {
|
|
1892
2084
|
const config = loadConfig();
|
|
2085
|
+
const logger = createLogger(config.logLevel);
|
|
1893
2086
|
if (config.zendeskEmail && config.zendeskApiToken) {
|
|
1894
2087
|
const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
|
|
1895
2088
|
const getToken = () => staticToken;
|
|
1896
|
-
await startStdioTransport(createMcpServer(config, getToken));
|
|
2089
|
+
await startStdioTransport(createMcpServer(config, getToken, logger), logger);
|
|
1897
2090
|
} else await startStdioTransport(createMcpServer(config, createTokenStore({
|
|
1898
2091
|
subdomain: config.subdomain,
|
|
1899
2092
|
oauthClientId: config.oauthClientId
|
|
1900
|
-
}).getToken));
|
|
2093
|
+
}, logger).getToken, logger), logger);
|
|
1901
2094
|
};
|
|
1902
2095
|
main().catch((error) => {
|
|
1903
2096
|
console.error("Fatal error:", error);
|