@fruggr/zendesk-mcp-server 2.15.0 → 2.16.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 +34 -15
- package/dist/index.js +736 -401
- package/package.json +2 -3
- package/dist/index.d.ts +0 -2
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import open from "open";
|
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import * as z from "zod/v4";
|
|
10
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import * as cheerio from "cheerio";
|
|
12
12
|
import { toHtml } from "hast-util-to-html";
|
|
13
13
|
import rehypeParse from "rehype-parse";
|
|
@@ -47,14 +47,22 @@ const REDACTED_KEYS = /* @__PURE__ */ new Set([
|
|
|
47
47
|
"bearer"
|
|
48
48
|
]);
|
|
49
49
|
const isSensitive = (key) => REDACTED_KEYS.has(key.toLowerCase().replace(/[_-]/g, ""));
|
|
50
|
-
const redactValue = (value) => {
|
|
51
|
-
if (
|
|
52
|
-
if (value
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
const redactValue = (value, path = /* @__PURE__ */ new WeakSet()) => {
|
|
51
|
+
if (typeof value === "function") return "[function]";
|
|
52
|
+
if (!value || typeof value !== "object") return value;
|
|
53
|
+
if (path.has(value)) return "[circular]";
|
|
54
|
+
path.add(value);
|
|
55
|
+
const out = Array.isArray(value) ? value.map((item) => redactValue(item, path)) : Object.fromEntries(Object.entries(value).map(([key, val]) => [key, isSensitive(key) ? "[REDACTED]" : redactValue(val, path)]));
|
|
56
|
+
path.delete(value);
|
|
57
|
+
return out;
|
|
58
|
+
};
|
|
59
|
+
const sanitise = (fields) => {
|
|
60
|
+
if (!fields) return {};
|
|
61
|
+
try {
|
|
62
|
+
return redactValue(fields);
|
|
63
|
+
} catch {
|
|
64
|
+
return { fields: "[unredactable]" };
|
|
56
65
|
}
|
|
57
|
-
return value;
|
|
58
66
|
};
|
|
59
67
|
const renderValue = (value) => {
|
|
60
68
|
if (typeof value === "string") return value;
|
|
@@ -82,8 +90,10 @@ const createLogger = (level) => {
|
|
|
82
90
|
let server;
|
|
83
91
|
const emit = (lvl, event, fields) => {
|
|
84
92
|
if (SEVERITY[lvl] < min) return;
|
|
85
|
-
const safe =
|
|
86
|
-
|
|
93
|
+
const safe = sanitise(fields);
|
|
94
|
+
try {
|
|
95
|
+
console.error(formatLine(lvl, event, safe));
|
|
96
|
+
} catch {}
|
|
87
97
|
if (server) try {
|
|
88
98
|
server.sendLoggingMessage({
|
|
89
99
|
level: MCP_LEVEL[lvl],
|
|
@@ -114,6 +124,7 @@ const positiveIntEnv = (name, fallback) => {
|
|
|
114
124
|
const parsed = Number(raw);
|
|
115
125
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
116
126
|
};
|
|
127
|
+
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
117
128
|
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
|
|
118
129
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
119
130
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
@@ -153,6 +164,8 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
|
|
|
153
164
|
* browser response; without escaping these are a reflected-XSS sink.
|
|
154
165
|
*/
|
|
155
166
|
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
167
|
+
const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
|
|
168
|
+
const SUCCESS_PAGE = "<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>";
|
|
156
169
|
const generateCodeVerifier = () => randomBytes(32).toString("base64url");
|
|
157
170
|
const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
158
171
|
/**
|
|
@@ -178,69 +191,92 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
178
191
|
});
|
|
179
192
|
let authTimeout;
|
|
180
193
|
let callbackServer;
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
const exchangeCodeForToken = async (code) => {
|
|
195
|
+
const callbackPort = callbackServer.address().port;
|
|
196
|
+
const tokenBody = new URLSearchParams({
|
|
197
|
+
grant_type: "authorization_code",
|
|
198
|
+
code,
|
|
199
|
+
client_id: oauthClientId,
|
|
200
|
+
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
201
|
+
code_verifier: codeVerifier
|
|
202
|
+
});
|
|
203
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
206
|
+
body: tokenBody.toString()
|
|
207
|
+
});
|
|
208
|
+
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
209
|
+
if (!tokenResponse.ok) {
|
|
210
|
+
const errorBody = await tokenResponse.text();
|
|
211
|
+
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
187
212
|
}
|
|
188
|
-
|
|
213
|
+
return await tokenResponse.json();
|
|
214
|
+
};
|
|
215
|
+
const finishRequest = (res, { status, html, outcome }) => {
|
|
216
|
+
res.writeHead(status, { "Content-Type": "text/html" });
|
|
217
|
+
res.end(html);
|
|
218
|
+
clearTimeout(authTimeout);
|
|
219
|
+
callbackServer.close();
|
|
220
|
+
if (outcome.ok) resolveToken(outcome.token);
|
|
221
|
+
else rejectToken(outcome.error);
|
|
222
|
+
};
|
|
223
|
+
const resolveCallback = async (url) => {
|
|
189
224
|
const error = url.searchParams.get("error");
|
|
225
|
+
const code = url.searchParams.get("code");
|
|
190
226
|
logger.debug("oauth_callback_received", {
|
|
191
227
|
hasCode: Boolean(code),
|
|
192
228
|
hasError: Boolean(error)
|
|
193
229
|
});
|
|
194
230
|
if (error) {
|
|
195
231
|
const desc = url.searchParams.get("error_description") ?? error;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
205
|
-
res.end("<html><body><h1>Missing authorization code</h1></body></html>");
|
|
206
|
-
clearTimeout(authTimeout);
|
|
207
|
-
callbackServer.close();
|
|
208
|
-
rejectToken(/* @__PURE__ */ new Error("Missing authorization code in callback"));
|
|
209
|
-
return;
|
|
232
|
+
return {
|
|
233
|
+
status: 400,
|
|
234
|
+
html: errorPage("Authentication failed", desc),
|
|
235
|
+
outcome: {
|
|
236
|
+
ok: false,
|
|
237
|
+
error: /* @__PURE__ */ new Error(`OAuth error: ${desc}`)
|
|
238
|
+
}
|
|
239
|
+
};
|
|
210
240
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
218
|
-
code_verifier: codeVerifier
|
|
219
|
-
});
|
|
220
|
-
const tokenResponse = await fetch(tokenUrl, {
|
|
221
|
-
method: "POST",
|
|
222
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
223
|
-
body: tokenBody.toString()
|
|
224
|
-
});
|
|
225
|
-
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
226
|
-
if (!tokenResponse.ok) {
|
|
227
|
-
const errorBody = await tokenResponse.text();
|
|
228
|
-
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
241
|
+
if (!code) return {
|
|
242
|
+
status: 400,
|
|
243
|
+
html: errorPage("Missing authorization code"),
|
|
244
|
+
outcome: {
|
|
245
|
+
ok: false,
|
|
246
|
+
error: /* @__PURE__ */ new Error("Missing authorization code in callback")
|
|
229
247
|
}
|
|
230
|
-
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
const token = await exchangeCodeForToken(code);
|
|
231
251
|
logger.info("oauth_authenticated");
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
252
|
+
return {
|
|
253
|
+
status: 200,
|
|
254
|
+
html: SUCCESS_PAGE,
|
|
255
|
+
outcome: {
|
|
256
|
+
ok: true,
|
|
257
|
+
token
|
|
258
|
+
}
|
|
259
|
+
};
|
|
237
260
|
} catch (err) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
261
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
262
|
+
return {
|
|
263
|
+
status: 500,
|
|
264
|
+
html: errorPage("Token exchange failed", detail),
|
|
265
|
+
outcome: {
|
|
266
|
+
ok: false,
|
|
267
|
+
error: err
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
callbackServer = createServer(async (req, res) => {
|
|
273
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
274
|
+
if (url.pathname !== "/callback") {
|
|
275
|
+
res.writeHead(404);
|
|
276
|
+
res.end("Not found");
|
|
277
|
+
return;
|
|
243
278
|
}
|
|
279
|
+
finishRequest(res, await resolveCallback(url));
|
|
244
280
|
});
|
|
245
281
|
const requestedPort = config.callbackPort ?? 27439;
|
|
246
282
|
const onStartError = (err) => {
|
|
@@ -383,9 +419,10 @@ const isWindows = process.platform === "win32";
|
|
|
383
419
|
* (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
|
|
384
420
|
* vendor-namespaced and can't collide with another `zendesk-mcp-server`.
|
|
385
421
|
*/
|
|
422
|
+
const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)$/;
|
|
386
423
|
const appDirSegments = () => {
|
|
387
424
|
const { name } = readPackageInfo();
|
|
388
|
-
const scoped =
|
|
425
|
+
const scoped = SCOPED_PACKAGE_NAME.exec(name);
|
|
389
426
|
return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
|
|
390
427
|
};
|
|
391
428
|
const configDir = () => {
|
|
@@ -521,20 +558,23 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
521
558
|
throw err;
|
|
522
559
|
});
|
|
523
560
|
};
|
|
561
|
+
const refreshIfPossible = async () => {
|
|
562
|
+
const current = token;
|
|
563
|
+
if (!current?.refreshToken) return void 0;
|
|
564
|
+
if (refreshing === void 0) refreshing = tryRefresh(current).finally(() => {
|
|
565
|
+
refreshing = void 0;
|
|
566
|
+
});
|
|
567
|
+
return refreshing;
|
|
568
|
+
};
|
|
524
569
|
const getToken = async () => {
|
|
525
|
-
if (refreshing) await refreshing;
|
|
570
|
+
if (refreshing !== void 0) await refreshing;
|
|
526
571
|
if (token && !needsRefresh(token)) {
|
|
527
572
|
logger.debug("oauth_token_cache_hit");
|
|
528
573
|
return token.accessToken;
|
|
529
574
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
});
|
|
534
|
-
const refreshed = await refreshing;
|
|
535
|
-
if (refreshed) return refreshed;
|
|
536
|
-
}
|
|
537
|
-
if (!starting) starting = beginAuth();
|
|
575
|
+
const refreshed = await refreshIfPossible();
|
|
576
|
+
if (refreshed) return refreshed;
|
|
577
|
+
if (starting === void 0) starting = beginAuth();
|
|
538
578
|
const url = authorizeUrl ?? await starting;
|
|
539
579
|
throw createAuthRequiredError(url);
|
|
540
580
|
};
|
|
@@ -603,6 +643,18 @@ const ConfigSchema = z.object({
|
|
|
603
643
|
*/
|
|
604
644
|
topology: z.boolean().default(true),
|
|
605
645
|
/**
|
|
646
|
+
* Whether to PRE-LIST the promoted ("featured") Help Center articles: the
|
|
647
|
+
* `<scheme>://article/{id}` resource's `list` callback (which scans `/articles`
|
|
648
|
+
* to enumerate the promoted set for `resources/list`) AND the
|
|
649
|
+
* `list_promoted_articles` tool. On by default; an operator disables the
|
|
650
|
+
* pre-listing with `--no-promoted-articles` (e.g. on a very large Help Center
|
|
651
|
+
* where scanning is costly) so the server issues zero preloading requests. This
|
|
652
|
+
* does NOT disable reading a known article by id (`<scheme>://article/{id}` stays
|
|
653
|
+
* registered) — that is cheap and on-demand. Only ever active when the
|
|
654
|
+
* `help_center` namespace itself is active.
|
|
655
|
+
*/
|
|
656
|
+
promotedArticles: z.boolean().default(true),
|
|
657
|
+
/**
|
|
606
658
|
* URI scheme of the Help Center MCP resources (today the topology resource,
|
|
607
659
|
* `<scheme>://topology`). Defaults to `zendesk-hc`; a deployer can brand it
|
|
608
660
|
* (`--hc-resource-scheme wiki` / `HC_RESOURCE_SCHEME=wiki`). Strictly a bare
|
|
@@ -644,75 +696,96 @@ const ConfigSchema = z.object({
|
|
|
644
696
|
corsOrigins: z.array(z.string().url().transform((value) => new URL(value).origin).refine((origin) => origin !== "null", { message: "CORS origin must be an http(s) URL with a host" })).default([]),
|
|
645
697
|
callbackPort: z.number().int().min(1).max(65535).optional()
|
|
646
698
|
});
|
|
699
|
+
const DIGITS_ONLY = /^\d+$/;
|
|
647
700
|
const parsePort = (raw, label) => {
|
|
648
|
-
if (
|
|
701
|
+
if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
|
|
649
702
|
return Number(raw);
|
|
650
703
|
};
|
|
651
704
|
const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
|
|
705
|
+
const appendTo = (key) => (result, value) => {
|
|
706
|
+
const list = result[key] ?? [];
|
|
707
|
+
list.push(value);
|
|
708
|
+
result[key] = list;
|
|
709
|
+
};
|
|
710
|
+
const STANDALONE_FLAGS = /* @__PURE__ */ new Map([
|
|
711
|
+
["--read-only", (result) => {
|
|
712
|
+
result.readOnly = true;
|
|
713
|
+
}],
|
|
714
|
+
["--no-topology", (result) => {
|
|
715
|
+
result.topology = false;
|
|
716
|
+
}],
|
|
717
|
+
["--no-promoted-articles", (result) => {
|
|
718
|
+
result.promotedArticles = false;
|
|
719
|
+
}],
|
|
720
|
+
["--dev", (result) => {
|
|
721
|
+
result.dev = true;
|
|
722
|
+
}]
|
|
723
|
+
]);
|
|
724
|
+
const VALUED_FLAGS = /* @__PURE__ */ new Map([
|
|
725
|
+
["--mode", (result, value) => {
|
|
726
|
+
result.mode = value;
|
|
727
|
+
}],
|
|
728
|
+
["--hc-resource-scheme", (result, value) => {
|
|
729
|
+
result.hcResourceScheme = value;
|
|
730
|
+
}],
|
|
731
|
+
["--log-level", (result, value) => {
|
|
732
|
+
result.logLevel = value;
|
|
733
|
+
}],
|
|
734
|
+
["--transport", (result, value) => {
|
|
735
|
+
result.transport = value;
|
|
736
|
+
}],
|
|
737
|
+
["--host", (result, value) => {
|
|
738
|
+
result.host = value;
|
|
739
|
+
}],
|
|
740
|
+
["--public-url", (result, value) => {
|
|
741
|
+
result.publicUrl = value;
|
|
742
|
+
}],
|
|
743
|
+
["--port", (result, value) => {
|
|
744
|
+
result.port = parsePort(value, "--port");
|
|
745
|
+
}],
|
|
746
|
+
["--callback-port", (result, value) => {
|
|
747
|
+
result.callbackPort = parsePort(value, "--callback-port");
|
|
748
|
+
}],
|
|
749
|
+
["--namespace", appendTo("namespaces")],
|
|
750
|
+
["--tool", appendTo("tools")],
|
|
751
|
+
["--cors-origin", appendTo("corsOrigins")]
|
|
752
|
+
]);
|
|
652
753
|
const parseCliArgs = (args) => {
|
|
653
754
|
const result = {};
|
|
654
|
-
let positionalIndex = 0;
|
|
655
755
|
for (let i = 0; i < args.length; i++) {
|
|
656
756
|
const arg = args[i];
|
|
657
757
|
if (arg === void 0) continue;
|
|
758
|
+
const standalone = STANDALONE_FLAGS.get(arg);
|
|
759
|
+
if (standalone) {
|
|
760
|
+
standalone(result);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const valued = VALUED_FLAGS.get(arg);
|
|
658
764
|
const next = args[i + 1];
|
|
659
|
-
if (
|
|
660
|
-
result
|
|
661
|
-
i++;
|
|
662
|
-
} else if (arg === "--read-only") result.readOnly = true;
|
|
663
|
-
else if (arg === "--no-topology") result.topology = false;
|
|
664
|
-
else if (arg === "--hc-resource-scheme" && next) {
|
|
665
|
-
result.hcResourceScheme = next;
|
|
666
|
-
i++;
|
|
667
|
-
} else if (arg === "--dev") result.dev = true;
|
|
668
|
-
else if (arg === "--namespace" && next) {
|
|
669
|
-
result.namespaces = result.namespaces ?? [];
|
|
670
|
-
result.namespaces.push(next);
|
|
671
|
-
i++;
|
|
672
|
-
} else if (arg === "--tool" && next) {
|
|
673
|
-
result.tools = result.tools ?? [];
|
|
674
|
-
result.tools.push(next);
|
|
675
|
-
i++;
|
|
676
|
-
} else if (arg === "--log-level" && next) {
|
|
677
|
-
result.logLevel = next;
|
|
678
|
-
i++;
|
|
679
|
-
} else if (arg === "--transport" && next) {
|
|
680
|
-
result.transport = next;
|
|
681
|
-
i++;
|
|
682
|
-
} else if (arg === "--host" && next) {
|
|
683
|
-
result.host = next;
|
|
684
|
-
i++;
|
|
685
|
-
} else if (arg === "--port" && next) {
|
|
686
|
-
result.port = parsePort(next, "--port");
|
|
687
|
-
i++;
|
|
688
|
-
} else if (arg === "--public-url" && next) {
|
|
689
|
-
result.publicUrl = next;
|
|
690
|
-
i++;
|
|
691
|
-
} else if (arg === "--cors-origin" && next) {
|
|
692
|
-
result.corsOrigins = result.corsOrigins ?? [];
|
|
693
|
-
result.corsOrigins.push(next);
|
|
694
|
-
i++;
|
|
695
|
-
} else if (arg === "--callback-port" && next) {
|
|
696
|
-
result.callbackPort = parsePort(next, "--callback-port");
|
|
765
|
+
if (valued && next) {
|
|
766
|
+
valued(result, next);
|
|
697
767
|
i++;
|
|
698
|
-
|
|
699
|
-
result.subdomain = arg;
|
|
700
|
-
positionalIndex++;
|
|
768
|
+
continue;
|
|
701
769
|
}
|
|
770
|
+
if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
|
|
702
771
|
}
|
|
703
772
|
return result;
|
|
704
773
|
};
|
|
774
|
+
const resolveTransportSettings = (cli) => {
|
|
775
|
+
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
776
|
+
return {
|
|
777
|
+
transport: cli.transport ?? process.env["TRANSPORT"] ?? "stdio",
|
|
778
|
+
host: cli.host ?? process.env["HOST"] ?? "0.0.0.0",
|
|
779
|
+
port: cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3,
|
|
780
|
+
publicUrl: cli.publicUrl ?? process.env["PUBLIC_URL"],
|
|
781
|
+
corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
|
|
782
|
+
};
|
|
783
|
+
};
|
|
705
784
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
706
785
|
const cli = parseCliArgs(argv);
|
|
707
786
|
const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
|
|
708
787
|
const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
|
|
709
788
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
710
|
-
const transport = cli.transport ?? process.env["TRANSPORT"] ?? "stdio";
|
|
711
|
-
const host = cli.host ?? process.env["HOST"] ?? "0.0.0.0";
|
|
712
|
-
const port = cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3;
|
|
713
|
-
const publicUrl = cli.publicUrl ?? process.env["PUBLIC_URL"];
|
|
714
|
-
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
715
|
-
const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
|
|
716
789
|
const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
717
790
|
const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
|
|
718
791
|
return ConfigSchema.parse({
|
|
@@ -724,14 +797,11 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
724
797
|
namespaces: cli.namespaces,
|
|
725
798
|
tools: cli.tools,
|
|
726
799
|
topology: cli.topology ?? true,
|
|
800
|
+
promotedArticles: cli.promotedArticles ?? true,
|
|
727
801
|
hcResourceScheme,
|
|
728
802
|
dev: cli.dev ?? false,
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
port,
|
|
732
|
-
publicUrl,
|
|
733
|
-
corsOrigins,
|
|
734
|
-
callbackPort
|
|
803
|
+
callbackPort,
|
|
804
|
+
...resolveTransportSettings(cli)
|
|
735
805
|
});
|
|
736
806
|
};
|
|
737
807
|
//#endregion
|
|
@@ -872,44 +942,100 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
|
872
942
|
return response.json();
|
|
873
943
|
};
|
|
874
944
|
//#endregion
|
|
875
|
-
//#region src/
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
945
|
+
//#region src/utils/article-sections.ts
|
|
946
|
+
const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
947
|
+
"h1",
|
|
948
|
+
"h2",
|
|
949
|
+
"h3"
|
|
950
|
+
]);
|
|
951
|
+
const WHITESPACE_RUN = /\s+/;
|
|
952
|
+
const countWords = (text) => {
|
|
953
|
+
const trimmed = text.trim();
|
|
954
|
+
if (!trimmed) return 0;
|
|
955
|
+
return trimmed.split(WHITESPACE_RUN).length;
|
|
956
|
+
};
|
|
957
|
+
const textOf = (html) => {
|
|
958
|
+
if (!html) return "";
|
|
959
|
+
return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
|
|
960
|
+
};
|
|
961
|
+
const parseSections = (html) => {
|
|
962
|
+
if (!html?.trim()) return [];
|
|
963
|
+
const $ = cheerio.load(html, null, false);
|
|
964
|
+
const children = $.root().contents().toArray();
|
|
965
|
+
const introParts = [];
|
|
966
|
+
const sections = [];
|
|
967
|
+
let current = null;
|
|
968
|
+
for (const node of children) {
|
|
969
|
+
const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
|
|
970
|
+
if (HEADING_LEVELS.has(tagName)) {
|
|
971
|
+
const level = Number.parseInt(tagName.slice(1), 10);
|
|
972
|
+
current = {
|
|
973
|
+
heading: $(node).text().trim(),
|
|
974
|
+
headingTag: tagName,
|
|
975
|
+
level,
|
|
976
|
+
contentParts: []
|
|
977
|
+
};
|
|
978
|
+
sections.push(current);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const outer = $.html(node);
|
|
982
|
+
if (current) current.contentParts.push(outer);
|
|
983
|
+
else introParts.push(outer);
|
|
984
|
+
}
|
|
985
|
+
const result = [];
|
|
986
|
+
if (introParts.length > 0) {
|
|
987
|
+
const introHtml = introParts.join("");
|
|
988
|
+
result.push({
|
|
989
|
+
index: 0,
|
|
990
|
+
heading: "intro",
|
|
991
|
+
headingTag: "",
|
|
992
|
+
level: 0,
|
|
993
|
+
html: introHtml,
|
|
994
|
+
wordCount: countWords(textOf(introHtml))
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
for (const s of sections) {
|
|
998
|
+
const sectionHtml = s.contentParts.join("");
|
|
999
|
+
result.push({
|
|
1000
|
+
index: result.length,
|
|
1001
|
+
heading: s.heading,
|
|
1002
|
+
headingTag: s.headingTag,
|
|
1003
|
+
level: s.level,
|
|
1004
|
+
html: sectionHtml,
|
|
1005
|
+
wordCount: countWords(textOf(sectionHtml))
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return result;
|
|
1009
|
+
};
|
|
1010
|
+
const replaceSectionContent = (html, sectionIndex, newHtml) => {
|
|
1011
|
+
const sections = parseSections(html);
|
|
1012
|
+
if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
|
|
1013
|
+
return sections.map((section, idx) => {
|
|
1014
|
+
const content = idx === sectionIndex ? newHtml : section.html;
|
|
1015
|
+
if (section.level === 0) return content;
|
|
1016
|
+
return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
|
|
1017
|
+
}).join("");
|
|
1018
|
+
};
|
|
1019
|
+
const keepAsHtml = (_state, node) => ({
|
|
1020
|
+
type: "html",
|
|
1021
|
+
value: toHtml(node)
|
|
1022
|
+
});
|
|
1023
|
+
const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
|
|
1024
|
+
table: keepAsHtml,
|
|
1025
|
+
pre: keepAsHtml
|
|
1026
|
+
} }).use(remarkGfm).use(remarkStringify, {
|
|
1027
|
+
bullet: "-",
|
|
1028
|
+
emphasis: "_",
|
|
1029
|
+
fences: true
|
|
1030
|
+
});
|
|
1031
|
+
const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
|
|
1032
|
+
const htmlToMarkdown = (html) => {
|
|
1033
|
+
if (!html) return "";
|
|
1034
|
+
return String(htmlToMdProcessor.processSync(html));
|
|
1035
|
+
};
|
|
1036
|
+
const markdownToHtml = (markdown) => {
|
|
1037
|
+
if (!markdown) return "";
|
|
1038
|
+
return String(mdToHtmlProcessor.processSync(markdown));
|
|
913
1039
|
};
|
|
914
1040
|
//#endregion
|
|
915
1041
|
//#region src/utils/formatting.ts
|
|
@@ -930,7 +1056,11 @@ const formatTicket = (ticket) => [
|
|
|
930
1056
|
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
931
1057
|
ticket.description ? `\n${ticket.description}` : ""
|
|
932
1058
|
].filter(Boolean).join("\n");
|
|
933
|
-
const formatConditionValue = (value) =>
|
|
1059
|
+
const formatConditionValue = (value) => {
|
|
1060
|
+
if (value === null || value === void 0) return "";
|
|
1061
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1062
|
+
return String(value);
|
|
1063
|
+
};
|
|
934
1064
|
const formatSlaPolicy = (policy) => {
|
|
935
1065
|
const conditions = [...policy.filter.all.map((c) => `all: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim()), ...policy.filter.any.map((c) => `any: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim())];
|
|
936
1066
|
const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
|
|
@@ -1107,6 +1237,7 @@ const formatArticleSummary = (article) => [
|
|
|
1107
1237
|
`## ${article.title} (${article.id})`,
|
|
1108
1238
|
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
1109
1239
|
`- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
|
|
1240
|
+
article.promoted ? "- **Promoted**: featured in its section — changing this requires Help Center admin (Guide admin) rights; set via update_article `promoted`." : "",
|
|
1110
1241
|
`- **Permission group**: ${article.permission_group_id} | **User segment**: ${article.user_segment_id ?? "everyone (no segment)"}`,
|
|
1111
1242
|
typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
|
|
1112
1243
|
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
@@ -1178,6 +1309,195 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
|
1178
1309
|
};
|
|
1179
1310
|
};
|
|
1180
1311
|
//#endregion
|
|
1312
|
+
//#region src/guidance/article-resources.ts
|
|
1313
|
+
/**
|
|
1314
|
+
* Name of the companion tool that lists promoted articles. Shared between the
|
|
1315
|
+
* tool definition (`help-center.ts`) and the `--no-promoted-articles` opt-out
|
|
1316
|
+
* filter (`server.ts`) so the two can never drift: renaming the tool here keeps
|
|
1317
|
+
* the filter dropping it, preserving the "zero preloading requests when off"
|
|
1318
|
+
* invariant.
|
|
1319
|
+
*/
|
|
1320
|
+
const LIST_PROMOTED_ARTICLES_TOOL = "list_promoted_articles";
|
|
1321
|
+
/**
|
|
1322
|
+
* Scan the Help Center for promoted ("featured") articles with the CALLER'S
|
|
1323
|
+
* token, so the result respects that user's read permissions. The API exposes no
|
|
1324
|
+
* server-side `promoted` filter (only label_names / sort), so we page through
|
|
1325
|
+
* `/articles` and filter `promoted` client-side, bounded by `maxPages` to keep
|
|
1326
|
+
* the scan tractable on a large Help Center. `truncated` signals the cap was hit.
|
|
1327
|
+
*
|
|
1328
|
+
* Returns the FULL promoted articles so callers that need rich metadata (the
|
|
1329
|
+
* `list_promoted_articles` tool) get everything; the resource provider maps these
|
|
1330
|
+
* down to lean refs before caching so the per-session cache doesn't retain bodies.
|
|
1331
|
+
*/
|
|
1332
|
+
const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOURCES_SCAN_MAX_PAGES) => {
|
|
1333
|
+
const promoted = [];
|
|
1334
|
+
let cursor;
|
|
1335
|
+
let pages = 0;
|
|
1336
|
+
let truncated = false;
|
|
1337
|
+
do {
|
|
1338
|
+
const response = await helpCenterGet(subdomain, token, "/articles", buildCursorParams(100, cursor));
|
|
1339
|
+
const articles = response.articles ?? [];
|
|
1340
|
+
for (const article of articles) if (article.promoted) promoted.push(article);
|
|
1341
|
+
pages += 1;
|
|
1342
|
+
const meta = extractPaginationMeta(response, articles.length);
|
|
1343
|
+
cursor = meta.has_more ? meta.after_cursor ?? void 0 : void 0;
|
|
1344
|
+
if (cursor && pages >= maxPages) {
|
|
1345
|
+
truncated = true;
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
} while (cursor);
|
|
1349
|
+
return {
|
|
1350
|
+
articles: promoted,
|
|
1351
|
+
truncated,
|
|
1352
|
+
pagesScanned: pages
|
|
1353
|
+
};
|
|
1354
|
+
};
|
|
1355
|
+
/**
|
|
1356
|
+
* Fetch a single article by id (optionally a translated locale) with the
|
|
1357
|
+
* caller's token and render it as Markdown: the shared metadata summary plus the
|
|
1358
|
+
* body converted from HTML (rather than a raw HTML dump), capped by the response
|
|
1359
|
+
* character limit. Reuses the same formatting as the `get_article` tool.
|
|
1360
|
+
*/
|
|
1361
|
+
const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
|
|
1362
|
+
const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${id}` : `/articles/${id}`);
|
|
1363
|
+
return truncateIfNeeded([
|
|
1364
|
+
formatArticleSummary(article),
|
|
1365
|
+
"",
|
|
1366
|
+
htmlToMarkdown(article.body)
|
|
1367
|
+
].join("\n"));
|
|
1368
|
+
};
|
|
1369
|
+
/**
|
|
1370
|
+
* Build an article-resources provider. `listPromoted` holds a memoized-promise
|
|
1371
|
+
* cache (TTL `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list`
|
|
1372
|
+
* calls a client makes; `readArticle` is a one-shot fetch (not cached). As with
|
|
1373
|
+
* `createTopologyProvider`, the cache is PER SESSION and must NOT be hoisted to
|
|
1374
|
+
* module scope — in HTTP mode this provider is instantiated per session, so a
|
|
1375
|
+
* shared cache would leak one caller's data to another. `getToken` is resolved
|
|
1376
|
+
* lazily at call time (never at construction) so connecting never triggers the
|
|
1377
|
+
* OAuth/PKCE flow. A 401 notifies `onUnauthorized` (stdio OAuth) to drop the
|
|
1378
|
+
* stale token, mirroring the topology provider and the tool dispatch path.
|
|
1379
|
+
*/
|
|
1380
|
+
const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized) => {
|
|
1381
|
+
let cached;
|
|
1382
|
+
const notifyIfUnauthorized = (err) => {
|
|
1383
|
+
if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
|
|
1384
|
+
};
|
|
1385
|
+
return {
|
|
1386
|
+
listPromoted() {
|
|
1387
|
+
const now = Date.now();
|
|
1388
|
+
if (cached && now - cached.at < 3e5) return cached.promise;
|
|
1389
|
+
const promise = (async () => {
|
|
1390
|
+
const token = await getToken();
|
|
1391
|
+
const { articles, truncated } = await fetchPromotedArticles(subdomain, token);
|
|
1392
|
+
return {
|
|
1393
|
+
refs: articles.map((a) => ({
|
|
1394
|
+
id: a.id,
|
|
1395
|
+
title: a.title
|
|
1396
|
+
})),
|
|
1397
|
+
truncated
|
|
1398
|
+
};
|
|
1399
|
+
})().catch((err) => {
|
|
1400
|
+
cached = void 0;
|
|
1401
|
+
notifyIfUnauthorized(err);
|
|
1402
|
+
throw err;
|
|
1403
|
+
});
|
|
1404
|
+
cached = {
|
|
1405
|
+
at: now,
|
|
1406
|
+
promise
|
|
1407
|
+
};
|
|
1408
|
+
return promise;
|
|
1409
|
+
},
|
|
1410
|
+
async readArticle(id) {
|
|
1411
|
+
try {
|
|
1412
|
+
const token = await getToken();
|
|
1413
|
+
return await fetchArticleMarkdown(subdomain, token, id);
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
notifyIfUnauthorized(err);
|
|
1416
|
+
throw err;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
};
|
|
1421
|
+
//#endregion
|
|
1422
|
+
//#region src/guidance/instructions.ts
|
|
1423
|
+
/**
|
|
1424
|
+
* URI of the dynamic Help Center topology resource. Single source of truth for
|
|
1425
|
+
* every place that cites it (resource registration, `instructions` blob): the
|
|
1426
|
+
* scheme comes from the config (`--hc-resource-scheme`, default `zendesk-hc`),
|
|
1427
|
+
* the path is fixed. Any future Help Center resource should build its URI the
|
|
1428
|
+
* same way so the whole surface follows the configured scheme.
|
|
1429
|
+
*/
|
|
1430
|
+
const topologyResourceUri = (config) => `${config.hcResourceScheme}://topology`;
|
|
1431
|
+
/**
|
|
1432
|
+
* URI template of the pull-only Help Center article resources, built from the
|
|
1433
|
+
* configured scheme exactly like `topologyResourceUri` (`--hc-resource-scheme`,
|
|
1434
|
+
* default `zendesk-hc` → `zendesk-hc://article/{id}`). The template's `list`
|
|
1435
|
+
* callback enumerates the promoted ("featured") articles (so clients can surface
|
|
1436
|
+
* them for pinning), while any article id can be read on demand.
|
|
1437
|
+
*/
|
|
1438
|
+
const articleResourceUriTemplate = (config) => `${config.hcResourceScheme}://article/{id}`;
|
|
1439
|
+
/**
|
|
1440
|
+
* Build the concrete resource URI for a single article id, under the configured
|
|
1441
|
+
* scheme. Shares the scheme with the template above so the listed URIs always
|
|
1442
|
+
* match the template the read callback is registered under.
|
|
1443
|
+
*/
|
|
1444
|
+
const articleResourceUri = (config, id) => `${config.hcResourceScheme}://article/${id}`;
|
|
1445
|
+
/**
|
|
1446
|
+
* Whether the `help_center` namespace is active: no `--namespace` filter, or one
|
|
1447
|
+
* that includes it. The shared second half of the two Help Center feature gates
|
|
1448
|
+
* below, so the namespace semantics live in one place.
|
|
1449
|
+
*/
|
|
1450
|
+
const helpCenterNamespaceActive = (config) => !config.namespaces?.length || config.namespaces.includes("help_center");
|
|
1451
|
+
/**
|
|
1452
|
+
* Whether the Help Center structural context (init instructions + the
|
|
1453
|
+
* topology resource, default `zendesk-hc://topology`) should be exposed. True only when the
|
|
1454
|
+
* feature is enabled (`--no-topology` not set) AND the `help_center` namespace
|
|
1455
|
+
* is active. Shared by the instructions builder and the resource registration in
|
|
1456
|
+
* `server.ts` so both gates stay in sync.
|
|
1457
|
+
*/
|
|
1458
|
+
const helpCenterContextEnabled = (config) => config.topology && helpCenterNamespaceActive(config);
|
|
1459
|
+
/**
|
|
1460
|
+
* Whether the read-by-id article resource (`<scheme>://article/{id}`) should be
|
|
1461
|
+
* registered. Available whenever the `help_center` namespace is active — reading
|
|
1462
|
+
* one article is a cheap, on-demand single fetch with NO preloading, so it is
|
|
1463
|
+
* deliberately NOT gated by the promoted-listing flag: `--no-promoted-articles`
|
|
1464
|
+
* turns off the costly pre-listing (below), never the ability to address a known
|
|
1465
|
+
* article id. Not tied to `--no-topology` either (topology is a separate feature).
|
|
1466
|
+
*/
|
|
1467
|
+
const articleResourceEnabled = (config) => helpCenterNamespaceActive(config);
|
|
1468
|
+
/**
|
|
1469
|
+
* Whether the promoted-article PRE-LISTING is exposed: the resource `list`
|
|
1470
|
+
* callback's scan (which enumerates the promoted articles for `resources/list`)
|
|
1471
|
+
* AND the `list_promoted_articles` tool. This is the costly, fan-out part (a capped
|
|
1472
|
+
* scan of `/articles`, no server-side promoted filter), so it gets its own flag —
|
|
1473
|
+
* `--no-promoted-articles` turns it off so the server issues zero preloading
|
|
1474
|
+
* requests, while read-by-id (above) stays available. `!== false` (not truthiness)
|
|
1475
|
+
* so an omitted flag on a hand-built Config stays default-on, matching the tool
|
|
1476
|
+
* filter in `server.ts`.
|
|
1477
|
+
*/
|
|
1478
|
+
const promotedArticlesEnabled = (config) => config.promotedArticles !== false && helpCenterNamespaceActive(config);
|
|
1479
|
+
/**
|
|
1480
|
+
* The static `instructions` blob sent on `initialize`. Deliberately short and
|
|
1481
|
+
* I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
|
|
1482
|
+
* it stays within a tight token budget. The rich, dynamic topology lives in the
|
|
1483
|
+
* pull-only topology resource (default `zendesk-hc://topology`) referenced here.
|
|
1484
|
+
*/
|
|
1485
|
+
const buildInstructions = (config) => {
|
|
1486
|
+
if (!helpCenterContextEnabled(config)) return void 0;
|
|
1487
|
+
return [
|
|
1488
|
+
`This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
|
|
1489
|
+
"",
|
|
1490
|
+
`When creating or editing Help Center content, the resource ${topologyResourceUri(config)} is useful context:`,
|
|
1491
|
+
"it lists the active locales (and the default one), the category → section tree with IDs,",
|
|
1492
|
+
"the visibility user segments, the permission groups, and your current role.",
|
|
1493
|
+
"Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
|
|
1494
|
+
"",
|
|
1495
|
+
"It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
|
|
1496
|
+
"user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
|
|
1497
|
+
"or user_segment_id from an existing article (get_article) instead."
|
|
1498
|
+
].join("\n");
|
|
1499
|
+
};
|
|
1500
|
+
//#endregion
|
|
1181
1501
|
//#region src/guidance/topology.ts
|
|
1182
1502
|
/**
|
|
1183
1503
|
* Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
|
|
@@ -1233,20 +1553,20 @@ const fetchTopology = async (subdomain, token) => {
|
|
|
1233
1553
|
currentUser: meRes.user
|
|
1234
1554
|
};
|
|
1235
1555
|
};
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1556
|
+
const renderOversizedTreeNotice = (data) => {
|
|
1557
|
+
const reasons = [];
|
|
1558
|
+
if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
|
|
1559
|
+
if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
|
|
1560
|
+
return [
|
|
1561
|
+
`Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
|
|
1562
|
+
data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
|
|
1563
|
+
...data.categories.map(formatCategory),
|
|
1564
|
+
"",
|
|
1565
|
+
...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
|
|
1566
|
+
"Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
|
|
1567
|
+
];
|
|
1568
|
+
};
|
|
1569
|
+
const renderCategoryTree = (data) => {
|
|
1250
1570
|
const byCategory = /* @__PURE__ */ new Map();
|
|
1251
1571
|
for (const section of data.sections) {
|
|
1252
1572
|
const list = byCategory.get(section.category_id) ?? [];
|
|
@@ -1260,6 +1580,7 @@ const renderTree = (data) => {
|
|
|
1260
1580
|
}
|
|
1261
1581
|
return lines.length ? lines : ["_(no categories)_"];
|
|
1262
1582
|
};
|
|
1583
|
+
const renderTree = (data) => data.categoriesHasMore || data.sectionsHasMore ? renderOversizedTreeNotice(data) : renderCategoryTree(data);
|
|
1263
1584
|
/**
|
|
1264
1585
|
* Render an admin-gated section as one of three states so the LLM never mistakes
|
|
1265
1586
|
* "you can't see this" for "there are none": the formatted list, `_(none)_` when
|
|
@@ -1413,101 +1734,6 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
|
|
|
1413
1734
|
return target === "before" ? movedIndex < refIndex : movedIndex > refIndex;
|
|
1414
1735
|
};
|
|
1415
1736
|
//#endregion
|
|
1416
|
-
//#region src/utils/article-sections.ts
|
|
1417
|
-
const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
1418
|
-
"h1",
|
|
1419
|
-
"h2",
|
|
1420
|
-
"h3"
|
|
1421
|
-
]);
|
|
1422
|
-
const countWords = (text) => {
|
|
1423
|
-
const trimmed = text.trim();
|
|
1424
|
-
if (!trimmed) return 0;
|
|
1425
|
-
return trimmed.split(/\s+/).length;
|
|
1426
|
-
};
|
|
1427
|
-
const textOf = (html) => {
|
|
1428
|
-
if (!html) return "";
|
|
1429
|
-
return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
|
|
1430
|
-
};
|
|
1431
|
-
const parseSections = (html) => {
|
|
1432
|
-
if (!html?.trim()) return [];
|
|
1433
|
-
const $ = cheerio.load(html, null, false);
|
|
1434
|
-
const children = $.root().contents().toArray();
|
|
1435
|
-
const introParts = [];
|
|
1436
|
-
const sections = [];
|
|
1437
|
-
let current = null;
|
|
1438
|
-
for (const node of children) {
|
|
1439
|
-
const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
|
|
1440
|
-
if (HEADING_LEVELS.has(tagName)) {
|
|
1441
|
-
const level = Number.parseInt(tagName.slice(1), 10);
|
|
1442
|
-
current = {
|
|
1443
|
-
heading: $(node).text().trim(),
|
|
1444
|
-
headingTag: tagName,
|
|
1445
|
-
level,
|
|
1446
|
-
contentParts: []
|
|
1447
|
-
};
|
|
1448
|
-
sections.push(current);
|
|
1449
|
-
continue;
|
|
1450
|
-
}
|
|
1451
|
-
const outer = $.html(node);
|
|
1452
|
-
if (current) current.contentParts.push(outer);
|
|
1453
|
-
else introParts.push(outer);
|
|
1454
|
-
}
|
|
1455
|
-
const result = [];
|
|
1456
|
-
if (introParts.length > 0) {
|
|
1457
|
-
const introHtml = introParts.join("");
|
|
1458
|
-
result.push({
|
|
1459
|
-
index: 0,
|
|
1460
|
-
heading: "intro",
|
|
1461
|
-
headingTag: "",
|
|
1462
|
-
level: 0,
|
|
1463
|
-
html: introHtml,
|
|
1464
|
-
wordCount: countWords(textOf(introHtml))
|
|
1465
|
-
});
|
|
1466
|
-
}
|
|
1467
|
-
for (const s of sections) {
|
|
1468
|
-
const sectionHtml = s.contentParts.join("");
|
|
1469
|
-
result.push({
|
|
1470
|
-
index: result.length,
|
|
1471
|
-
heading: s.heading,
|
|
1472
|
-
headingTag: s.headingTag,
|
|
1473
|
-
level: s.level,
|
|
1474
|
-
html: sectionHtml,
|
|
1475
|
-
wordCount: countWords(textOf(sectionHtml))
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
return result;
|
|
1479
|
-
};
|
|
1480
|
-
const replaceSectionContent = (html, sectionIndex, newHtml) => {
|
|
1481
|
-
const sections = parseSections(html);
|
|
1482
|
-
if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
|
|
1483
|
-
return sections.map((section, idx) => {
|
|
1484
|
-
const content = idx === sectionIndex ? newHtml : section.html;
|
|
1485
|
-
if (section.level === 0) return content;
|
|
1486
|
-
return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
|
|
1487
|
-
}).join("");
|
|
1488
|
-
};
|
|
1489
|
-
const keepAsHtml = (_state, node) => ({
|
|
1490
|
-
type: "html",
|
|
1491
|
-
value: toHtml(node)
|
|
1492
|
-
});
|
|
1493
|
-
const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
|
|
1494
|
-
table: keepAsHtml,
|
|
1495
|
-
pre: keepAsHtml
|
|
1496
|
-
} }).use(remarkGfm).use(remarkStringify, {
|
|
1497
|
-
bullet: "-",
|
|
1498
|
-
emphasis: "_",
|
|
1499
|
-
fences: true
|
|
1500
|
-
});
|
|
1501
|
-
const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
|
|
1502
|
-
const htmlToMarkdown = (html) => {
|
|
1503
|
-
if (!html) return "";
|
|
1504
|
-
return String(htmlToMdProcessor.processSync(html));
|
|
1505
|
-
};
|
|
1506
|
-
const markdownToHtml = (markdown) => {
|
|
1507
|
-
if (!markdown) return "";
|
|
1508
|
-
return String(mdToHtmlProcessor.processSync(markdown));
|
|
1509
|
-
};
|
|
1510
|
-
//#endregion
|
|
1511
1737
|
//#region src/tools/help-center.ts
|
|
1512
1738
|
const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
|
|
1513
1739
|
const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
|
|
@@ -1521,6 +1747,60 @@ const largeArticleHint = (body, sectionCount) => {
|
|
|
1521
1747
|
].join("\n");
|
|
1522
1748
|
};
|
|
1523
1749
|
const autoSortNotice = (sectionId, applied) => [applied === void 0 ? `Section #${sectionId} looks like it is sorted automatically, so a manual reorder would have no visible effect.` : `Wrote ${applied} article position(s), but the display order of section #${sectionId} did not change — the section is sorted automatically, so positions are ignored.`, "To order its articles manually: in Guide, open the section, choose \"Edit section\", set \"Order articles by\" to Manual, then re-run this tool."].join(" ");
|
|
1750
|
+
const renderFreshnessLine = (sourceUpdatedAt, targetUpdatedAt, targetLocale) => {
|
|
1751
|
+
const srcMs = Date.parse(sourceUpdatedAt);
|
|
1752
|
+
const tgtMs = Date.parse(targetUpdatedAt);
|
|
1753
|
+
const label = `- **Freshness (target ${targetLocale})**`;
|
|
1754
|
+
if (!Number.isFinite(srcMs) || !Number.isFinite(tgtMs)) return `${label}: unknown (could not compare edit timestamps).`;
|
|
1755
|
+
if (srcMs <= tgtMs) return `${label}: up to date (source has not been edited since this translation).`;
|
|
1756
|
+
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
1757
|
+
return `${label}: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
1758
|
+
};
|
|
1759
|
+
const renderOutdatedLine = (translations, targetLocale) => {
|
|
1760
|
+
const targetLocaleKey = targetLocale.toLowerCase();
|
|
1761
|
+
const entry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
1762
|
+
const label = `- **Zendesk outdated flag (target ${targetLocale})**`;
|
|
1763
|
+
if (entry?.outdated === void 0) return `${label}: unknown.`;
|
|
1764
|
+
if (entry.outdated) return `${label}: yes — explicitly marked out of date in Guide.`;
|
|
1765
|
+
return `${label}: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).`;
|
|
1766
|
+
};
|
|
1767
|
+
const renderStructureLine = (sourceSections, targetSections) => {
|
|
1768
|
+
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
1769
|
+
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
1770
|
+
return sourceSections.length === targetSections.length && sourceTags === targetTags ? `- **Structure**: ${sourceSections.length} sections in both locales — aligned.` : `- **Structure**: ${sourceSections.length} source vs ${targetSections.length} target sections — MISMATCH; the per-index rows below may be misaligned.`;
|
|
1771
|
+
};
|
|
1772
|
+
const sectionRowStatus = (source, target) => {
|
|
1773
|
+
if (!target) return "missing";
|
|
1774
|
+
if (!source) return "extra";
|
|
1775
|
+
return "ok";
|
|
1776
|
+
};
|
|
1777
|
+
const renderSectionRows = (sourceSections, targetSections) => {
|
|
1778
|
+
const rows = ["| Idx | Heading | Status | Source words | Target words |", "| --- | --- | --- | --- | --- |"];
|
|
1779
|
+
const maxLen = Math.max(sourceSections.length, targetSections.length);
|
|
1780
|
+
for (let i = 0; i < maxLen; i += 1) {
|
|
1781
|
+
const src = sourceSections[i];
|
|
1782
|
+
const tgt = targetSections[i];
|
|
1783
|
+
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
1784
|
+
const status = sectionRowStatus(src, tgt);
|
|
1785
|
+
rows.push(`| ${i} | ${heading} | ${status} | ${src?.wordCount ?? 0} | ${tgt?.wordCount ?? 0} |`);
|
|
1786
|
+
}
|
|
1787
|
+
return rows;
|
|
1788
|
+
};
|
|
1789
|
+
const localePrefix = (locale) => locale ? `/${locale}` : "";
|
|
1790
|
+
const articleListPath = (sectionId, locale) => `${localePrefix(locale)}${sectionId ? `/sections/${sectionId}` : ""}/articles`;
|
|
1791
|
+
const sectionListPath = (categoryId, locale) => `${localePrefix(locale)}${categoryId ? `/categories/${categoryId}` : ""}/sections`;
|
|
1792
|
+
const scanCostNote = (truncated, pagesScanned, cost) => {
|
|
1793
|
+
if (truncated) return `\n\n_Note: the scan hit its ${ARTICLE_RESOURCES_SCAN_MAX_PAGES}-page cap (${cost}), so promoted articles deeper in the catalog may be missing. This call is costly on this Help Center — avoid repeating it; raise ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES to widen coverage._`;
|
|
1794
|
+
if (pagesScanned > 1) return `\n\n_Note: this scan cost ${cost}; this tool performs a fresh scan every call (no caching), so avoid calling it again right away._`;
|
|
1795
|
+
return "";
|
|
1796
|
+
};
|
|
1797
|
+
const needsReferenceArticle = (target) => target === "before" || target === "after";
|
|
1798
|
+
const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
|
|
1799
|
+
const needsReference = needsReferenceArticle(target);
|
|
1800
|
+
if (needsReference && referenceArticleId === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
|
|
1801
|
+
if (!needsReference && referenceArticleId !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
1802
|
+
if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
|
|
1803
|
+
};
|
|
1524
1804
|
const createHelpCenterTools = (ctx) => {
|
|
1525
1805
|
const { subdomain, getToken } = ctx;
|
|
1526
1806
|
const fetchSectionOrder = async (sectionId, locale, token) => {
|
|
@@ -1538,6 +1818,26 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1538
1818
|
} while (cursor);
|
|
1539
1819
|
return order;
|
|
1540
1820
|
};
|
|
1821
|
+
const assertReferenceInSection = async (effective, referenceArticleId, articleId, sectionId, token) => {
|
|
1822
|
+
if (effective.some((a) => a.id === referenceArticleId)) return;
|
|
1823
|
+
let detail = "was not found";
|
|
1824
|
+
try {
|
|
1825
|
+
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${referenceArticleId}`);
|
|
1826
|
+
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
1827
|
+
} catch {}
|
|
1828
|
+
throw new Error(`Reference article #${referenceArticleId} ${detail}. It must be in the same section (#${sectionId}) as article #${articleId}.`);
|
|
1829
|
+
};
|
|
1830
|
+
const applyPositionWrites = async (writes, articleId, token) => {
|
|
1831
|
+
let applied = 0;
|
|
1832
|
+
for (const write of writes) try {
|
|
1833
|
+
await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
|
|
1834
|
+
applied += 1;
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1837
|
+
throw new Error(`Reorder of article #${articleId} failed after ${applied}/${writes.length} position write(s) (on article #${write.id}): ${reason} Positions are written absolutely, so re-running the identical call is safe and resumes where it stopped.`, { cause: error });
|
|
1838
|
+
}
|
|
1839
|
+
return applied;
|
|
1840
|
+
};
|
|
1541
1841
|
return [
|
|
1542
1842
|
{
|
|
1543
1843
|
name: "search_articles",
|
|
@@ -1620,7 +1920,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1620
1920
|
handler: async (params) => {
|
|
1621
1921
|
const { locale, page_size, cursor } = params;
|
|
1622
1922
|
const token = await getToken();
|
|
1623
|
-
const path =
|
|
1923
|
+
const path = `${localePrefix(locale)}/categories`;
|
|
1624
1924
|
const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
|
|
1625
1925
|
const categories = response.categories ?? [];
|
|
1626
1926
|
return { content: [{
|
|
@@ -1650,8 +1950,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1650
1950
|
handler: async (params) => {
|
|
1651
1951
|
const { category_id, locale, page_size, cursor } = params;
|
|
1652
1952
|
const token = await getToken();
|
|
1653
|
-
const
|
|
1654
|
-
const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
|
|
1953
|
+
const response = await helpCenterGet(subdomain, token, sectionListPath(category_id, locale), buildCursorParams(page_size, cursor));
|
|
1655
1954
|
const sections = response.sections ?? [];
|
|
1656
1955
|
return { content: [{
|
|
1657
1956
|
type: "text",
|
|
@@ -1688,8 +1987,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1688
1987
|
handler: async (params) => {
|
|
1689
1988
|
const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
|
|
1690
1989
|
const token = await getToken();
|
|
1691
|
-
const
|
|
1692
|
-
const response = await helpCenterGet(subdomain, token, path, {
|
|
1990
|
+
const response = await helpCenterGet(subdomain, token, articleListPath(section_id, locale), {
|
|
1693
1991
|
...buildCursorParams(page_size, cursor),
|
|
1694
1992
|
sort_by,
|
|
1695
1993
|
sort_order
|
|
@@ -1710,6 +2008,31 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1710
2008
|
}] };
|
|
1711
2009
|
}
|
|
1712
2010
|
},
|
|
2011
|
+
{
|
|
2012
|
+
name: LIST_PROMOTED_ARTICLES_TOOL,
|
|
2013
|
+
namespace: "help_center",
|
|
2014
|
+
readOnly: true,
|
|
2015
|
+
title: "List Promoted Help Center Articles",
|
|
2016
|
+
description: "List the promoted (\"featured\") Help Center articles — the small, editorially-curated set surfaced at the top of their sections. Returns metadata only (no body); use get_article for full content. COST: the Help Center API has no server-side promoted filter, so this scans article pages (one Zendesk API request per page, up to ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES, default 20) and filters client-side — potentially costly on a large Help Center. Each call performs a fresh, uncached scan, so avoid calling it repeatedly. On a very large Help Center some promoted articles may be omitted, and both the omission and the number of pages scanned are flagged in the output. Lists the default locale. To promote or unpromote an article, use update_article with `promoted` (requires Help Center admin / Guide admin rights).",
|
|
2017
|
+
inputSchema: z.object({}),
|
|
2018
|
+
annotations: {
|
|
2019
|
+
readOnlyHint: true,
|
|
2020
|
+
destructiveHint: false,
|
|
2021
|
+
idempotentHint: true,
|
|
2022
|
+
openWorldHint: true
|
|
2023
|
+
},
|
|
2024
|
+
handler: async () => {
|
|
2025
|
+
const token = await getToken();
|
|
2026
|
+
const { articles, truncated, pagesScanned } = await fetchPromotedArticles(subdomain, token);
|
|
2027
|
+
const header = `Promoted (featured) articles: ${articles.length}`;
|
|
2028
|
+
const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
|
|
2029
|
+
const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
|
|
2030
|
+
return { content: [{
|
|
2031
|
+
type: "text",
|
|
2032
|
+
text: truncateIfNeeded(`${header}\n\n${body}${scanCostNote(truncated, pagesScanned, cost)}`)
|
|
2033
|
+
}] };
|
|
2034
|
+
}
|
|
2035
|
+
},
|
|
1713
2036
|
{
|
|
1714
2037
|
name: "list_article_translations",
|
|
1715
2038
|
namespace: "help_center",
|
|
@@ -1918,23 +2241,14 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1918
2241
|
},
|
|
1919
2242
|
handler: async (params) => {
|
|
1920
2243
|
const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
if (!needsReference && reference_article_id !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
1924
|
-
if (reference_article_id !== void 0 && reference_article_id === article_id) throw new Error("reference_article_id must differ from article_id.");
|
|
2244
|
+
assertReorderParamsCoherent(article_id, target, reference_article_id);
|
|
2245
|
+
const needsReference = needsReferenceArticle(target);
|
|
1925
2246
|
const token = await getToken();
|
|
1926
2247
|
const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
|
|
1927
2248
|
const sectionId = article.section_id;
|
|
1928
2249
|
const locale = article.source_locale;
|
|
1929
2250
|
const effective = await fetchSectionOrder(sectionId, locale, token);
|
|
1930
|
-
if (
|
|
1931
|
-
let detail = "was not found";
|
|
1932
|
-
try {
|
|
1933
|
-
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
|
|
1934
|
-
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
1935
|
-
} catch {}
|
|
1936
|
-
throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
|
|
1937
|
-
}
|
|
2251
|
+
if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
|
|
1938
2252
|
const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
|
|
1939
2253
|
const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
|
|
1940
2254
|
if (writes.length === 0) return { content: [{
|
|
@@ -1949,14 +2263,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1949
2263
|
type: "text",
|
|
1950
2264
|
text: `Reordering article #${article_id} to ${targetLabel} would reposition ${writes.length} articles in section #${sectionId}, above the safety threshold of ${REORDER_CONFIRM_THRESHOLD}. Re-run with confirm: true to proceed.`
|
|
1951
2265
|
}] };
|
|
1952
|
-
|
|
1953
|
-
for (const write of writes) try {
|
|
1954
|
-
await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
|
|
1955
|
-
applied += 1;
|
|
1956
|
-
} catch (error) {
|
|
1957
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
1958
|
-
throw new Error(`Reorder of article #${article_id} failed after ${applied}/${writes.length} position write(s) (on article #${write.id}): ${reason} Positions are written absolutely, so re-running the identical call is safe and resumes where it stopped.`, { cause: error });
|
|
1959
|
-
}
|
|
2266
|
+
const applied = await applyPositionWrites(writes, article_id, token);
|
|
1960
2267
|
if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
|
|
1961
2268
|
type: "text",
|
|
1962
2269
|
text: autoSortNotice(sectionId, applied)
|
|
@@ -2268,40 +2575,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2268
2575
|
]);
|
|
2269
2576
|
const sourceSections = parseSections(sourceRes.translation.body);
|
|
2270
2577
|
const targetSections = parseSections(targetRes.translation.body);
|
|
2271
|
-
const
|
|
2272
|
-
const
|
|
2273
|
-
const
|
|
2274
|
-
const
|
|
2275
|
-
const tgtMs = Date.parse(targetUpdated);
|
|
2276
|
-
const comparable = Number.isFinite(srcMs) && Number.isFinite(tgtMs);
|
|
2277
|
-
let freshnessLine;
|
|
2278
|
-
if (comparable && srcMs > tgtMs) {
|
|
2279
|
-
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
2280
|
-
freshnessLine = `- **Freshness (target ${target_locale})**: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
2281
|
-
} else if (comparable) freshnessLine = `- **Freshness (target ${target_locale})**: up to date (source has not been edited since this translation).`;
|
|
2282
|
-
else freshnessLine = `- **Freshness (target ${target_locale})**: unknown (could not compare edit timestamps).`;
|
|
2283
|
-
const targetLocaleKey = target_locale.toLowerCase();
|
|
2284
|
-
const targetListEntry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
2285
|
-
const outdated = targetListEntry?.outdated === void 0 ? "unknown" : targetListEntry.outdated ? "yes" : "no";
|
|
2286
|
-
const outdatedLine = outdated === "yes" ? `- **Zendesk outdated flag (target ${target_locale})**: yes — explicitly marked out of date in Guide.` : outdated === "no" ? `- **Zendesk outdated flag (target ${target_locale})**: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).` : `- **Zendesk outdated flag (target ${target_locale})**: unknown.`;
|
|
2287
|
-
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
2288
|
-
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
2289
|
-
const structureLine = sourceSections.length === targetSections.length && sourceTags === targetTags ? `- **Structure**: ${sourceSections.length} sections in both locales — aligned.` : `- **Structure**: ${sourceSections.length} source vs ${targetSections.length} target sections — MISMATCH; the per-index rows below may be misaligned.`;
|
|
2290
|
-
const rows = [];
|
|
2291
|
-
rows.push(`| Idx | Heading | Status | Source words | Target words |`);
|
|
2292
|
-
rows.push(`| --- | --- | --- | --- | --- |`);
|
|
2293
|
-
for (let i = 0; i < maxLen; i += 1) {
|
|
2294
|
-
const src = sourceSections[i];
|
|
2295
|
-
const tgt = targetSections[i];
|
|
2296
|
-
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
2297
|
-
const sourceWords = src?.wordCount ?? 0;
|
|
2298
|
-
const targetWords = tgt?.wordCount ?? 0;
|
|
2299
|
-
let status;
|
|
2300
|
-
if (!tgt) status = "missing";
|
|
2301
|
-
else if (!src) status = "extra";
|
|
2302
|
-
else status = "ok";
|
|
2303
|
-
rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
|
|
2304
|
-
}
|
|
2578
|
+
const freshnessLine = renderFreshnessLine(sourceRes.translation.updated_at, targetRes.translation.updated_at, target_locale);
|
|
2579
|
+
const outdatedLine = renderOutdatedLine(translations, target_locale);
|
|
2580
|
+
const structureLine = renderStructureLine(sourceSections, targetSections);
|
|
2581
|
+
const rows = renderSectionRows(sourceSections, targetSections);
|
|
2305
2582
|
return { content: [{
|
|
2306
2583
|
type: "text",
|
|
2307
2584
|
text: [
|
|
@@ -2310,7 +2587,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2310
2587
|
freshnessLine,
|
|
2311
2588
|
outdatedLine,
|
|
2312
2589
|
structureLine,
|
|
2313
|
-
`- **Updated**: source ${
|
|
2590
|
+
`- **Updated**: source ${sourceRes.translation.updated_at} | target ${targetRes.translation.updated_at}`,
|
|
2314
2591
|
`- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
|
|
2315
2592
|
"",
|
|
2316
2593
|
"_Word counts are informational: a length difference between languages is normal, not a divergence._",
|
|
@@ -2406,6 +2683,7 @@ const createSearchTools = (ctx) => {
|
|
|
2406
2683
|
};
|
|
2407
2684
|
//#endregion
|
|
2408
2685
|
//#region src/tools/tickets.ts
|
|
2686
|
+
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2));
|
|
2409
2687
|
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
2410
2688
|
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
2411
2689
|
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
@@ -2434,6 +2712,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
|
2434
2712
|
}
|
|
2435
2713
|
return all;
|
|
2436
2714
|
};
|
|
2715
|
+
const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
2716
|
+
const attachments = [];
|
|
2717
|
+
for (const id of ids) try {
|
|
2718
|
+
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
2719
|
+
attachments.push(attachment);
|
|
2720
|
+
} catch (error) {
|
|
2721
|
+
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
2722
|
+
}
|
|
2723
|
+
return attachments;
|
|
2724
|
+
};
|
|
2437
2725
|
const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
2438
2726
|
const blocks = [];
|
|
2439
2727
|
let embeddedCount = 0;
|
|
@@ -2447,7 +2735,7 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
2447
2735
|
continue;
|
|
2448
2736
|
}
|
|
2449
2737
|
let skipReason = null;
|
|
2450
|
-
if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${
|
|
2738
|
+
if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
|
|
2451
2739
|
else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
|
|
2452
2740
|
if (skipReason) {
|
|
2453
2741
|
blocks.push({
|
|
@@ -2469,9 +2757,10 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
2469
2757
|
}
|
|
2470
2758
|
return blocks;
|
|
2471
2759
|
};
|
|
2760
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
2472
2761
|
const fetchTicketSla = async (subdomain, token, ticket) => {
|
|
2473
2762
|
const day = ticket.created_at.slice(0, 10);
|
|
2474
|
-
if (
|
|
2763
|
+
if (!ISO_DAY.test(day)) return void 0;
|
|
2475
2764
|
const shiftDay = (offset) => {
|
|
2476
2765
|
const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
|
|
2477
2766
|
d.setUTCDate(d.getUTCDate() + offset);
|
|
@@ -2541,23 +2830,24 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
|
|
|
2541
2830
|
const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
|
|
2542
2831
|
return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
2543
2832
|
};
|
|
2833
|
+
const addPositiveId = (set, raw) => {
|
|
2834
|
+
const n = Number(raw);
|
|
2835
|
+
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
2836
|
+
};
|
|
2837
|
+
const collectEventIds = (event, userIds, groupIds) => {
|
|
2838
|
+
if (event.type !== "Change" && event.type !== "Create") return;
|
|
2839
|
+
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
2840
|
+
if (!entity) return;
|
|
2841
|
+
const set = entity === "user" ? userIds : groupIds;
|
|
2842
|
+
addPositiveId(set, event.value);
|
|
2843
|
+
addPositiveId(set, event.previous_value);
|
|
2844
|
+
};
|
|
2544
2845
|
const collectAuditIds = (audits) => {
|
|
2545
2846
|
const userIds = /* @__PURE__ */ new Set();
|
|
2546
2847
|
const groupIds = /* @__PURE__ */ new Set();
|
|
2547
|
-
const addId = (set, raw) => {
|
|
2548
|
-
const n = Number(raw);
|
|
2549
|
-
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
2550
|
-
};
|
|
2551
2848
|
for (const audit of audits) {
|
|
2552
|
-
|
|
2553
|
-
for (const event of audit.events)
|
|
2554
|
-
if (event.type !== "Change" && event.type !== "Create") continue;
|
|
2555
|
-
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
2556
|
-
if (!entity) continue;
|
|
2557
|
-
const set = entity === "user" ? userIds : groupIds;
|
|
2558
|
-
addId(set, event.value);
|
|
2559
|
-
addId(set, event.previous_value);
|
|
2560
|
-
}
|
|
2849
|
+
addPositiveId(userIds, audit.author_id);
|
|
2850
|
+
for (const event of audit.events) collectEventIds(event, userIds, groupIds);
|
|
2561
2851
|
}
|
|
2562
2852
|
return {
|
|
2563
2853
|
userIds: [...userIds],
|
|
@@ -2600,12 +2890,11 @@ const diffLine = (label, before, after) => {
|
|
|
2600
2890
|
const a = shownValue(after);
|
|
2601
2891
|
return b === a ? null : `- **${label}**: ${b} → ${a}`;
|
|
2602
2892
|
};
|
|
2603
|
-
const
|
|
2604
|
-
|
|
2605
|
-
const beforeObj = before
|
|
2606
|
-
const comment = after.comment ?? result?.comment;
|
|
2893
|
+
const asRecord = (value) => value ?? {};
|
|
2894
|
+
const diffStandardFields = (before, after) => {
|
|
2895
|
+
const beforeObj = asRecord(before);
|
|
2607
2896
|
const changes = [];
|
|
2608
|
-
for (const [key, afterVal] of Object.entries(after)) {
|
|
2897
|
+
for (const [key, afterVal] of Object.entries(asRecord(after))) {
|
|
2609
2898
|
if (DIFF_SKIP_KEYS.has(key)) continue;
|
|
2610
2899
|
const beforeVal = beforeObj[key];
|
|
2611
2900
|
if (valuesEqual(beforeVal, afterVal)) continue;
|
|
@@ -2618,12 +2907,22 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
|
2618
2907
|
const line = diffLine(key, beforeVal, afterVal);
|
|
2619
2908
|
if (line) changes.push(line);
|
|
2620
2909
|
}
|
|
2621
|
-
|
|
2910
|
+
return changes;
|
|
2911
|
+
};
|
|
2912
|
+
const diffCustomFields = (before, after) => {
|
|
2913
|
+
const afterFields = [after?.fields ?? after?.custom_fields ?? []].flat();
|
|
2622
2914
|
const beforeById = new Map((before?.custom_fields ?? []).map((f) => [f.id, f.value]));
|
|
2915
|
+
const changes = [];
|
|
2623
2916
|
for (const f of afterFields) {
|
|
2624
2917
|
const line = diffLine(`custom field ${f.id}`, beforeById.get(f.id), f.value);
|
|
2625
2918
|
if (line) changes.push(line);
|
|
2626
2919
|
}
|
|
2920
|
+
return changes;
|
|
2921
|
+
};
|
|
2922
|
+
const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
2923
|
+
const after = result?.ticket ?? {};
|
|
2924
|
+
const comment = after.comment ?? result?.comment;
|
|
2925
|
+
const changes = [...diffStandardFields(before, after), ...diffCustomFields(before, after)];
|
|
2627
2926
|
const lines = [
|
|
2628
2927
|
`# Macro #${macroId} preview on ticket #${ticketId} (diff — nothing saved yet)`,
|
|
2629
2928
|
"",
|
|
@@ -2749,16 +3048,7 @@ const createTicketTools = (ctx) => {
|
|
|
2749
3048
|
handler: async (params) => {
|
|
2750
3049
|
const { ticket_id, attachment_ids } = params;
|
|
2751
3050
|
const token = await getToken();
|
|
2752
|
-
|
|
2753
|
-
if (attachment_ids && attachment_ids.length > 0) {
|
|
2754
|
-
attachments = [];
|
|
2755
|
-
for (const id of attachment_ids) try {
|
|
2756
|
-
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
2757
|
-
attachments.push(attachment);
|
|
2758
|
-
} catch (error) {
|
|
2759
|
-
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
2760
|
-
}
|
|
2761
|
-
} else attachments = (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
3051
|
+
const attachments = attachment_ids && attachment_ids.length > 0 ? await fetchAttachmentsByIds(subdomain, token, attachment_ids) : (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
2762
3052
|
if (attachments.length === 0) return { content: [{
|
|
2763
3053
|
type: "text",
|
|
2764
3054
|
text: `No attachments found on ticket #${ticket_id}.`
|
|
@@ -3541,7 +3831,7 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3541
3831
|
readOnly: config.readOnly,
|
|
3542
3832
|
namespaces: config.namespaces,
|
|
3543
3833
|
tools: config.tools
|
|
3544
|
-
});
|
|
3834
|
+
}).filter((t) => config.promotedArticles !== false || t.name !== "list_promoted_articles");
|
|
3545
3835
|
try {
|
|
3546
3836
|
switch (config.mode) {
|
|
3547
3837
|
case "all":
|
|
@@ -3563,6 +3853,10 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3563
3853
|
case "single":
|
|
3564
3854
|
registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
|
|
3565
3855
|
break;
|
|
3856
|
+
default: {
|
|
3857
|
+
const unhandled = config.mode;
|
|
3858
|
+
throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
|
|
3859
|
+
}
|
|
3566
3860
|
}
|
|
3567
3861
|
if (helpCenterContextEnabled(config)) {
|
|
3568
3862
|
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
@@ -3576,6 +3870,44 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3576
3870
|
text: await topology.read()
|
|
3577
3871
|
}] })));
|
|
3578
3872
|
}
|
|
3873
|
+
if (articleResourceEnabled(config)) {
|
|
3874
|
+
const articles = createArticleResourcesProvider(getToken, config.subdomain, onUnauthorized);
|
|
3875
|
+
const listPromotedEnabled = promotedArticlesEnabled(config);
|
|
3876
|
+
const template = new ResourceTemplate(articleResourceUriTemplate(config), { list: async () => {
|
|
3877
|
+
if (!listPromotedEnabled) return { resources: [] };
|
|
3878
|
+
try {
|
|
3879
|
+
const { refs, truncated } = await articles.listPromoted();
|
|
3880
|
+
if (truncated) logger.warn("article_resources_list_truncated", {
|
|
3881
|
+
max_pages: ARTICLE_RESOURCES_SCAN_MAX_PAGES,
|
|
3882
|
+
listed: refs.length
|
|
3883
|
+
});
|
|
3884
|
+
return { resources: refs.map((ref) => ({
|
|
3885
|
+
uri: articleResourceUri(config, ref.id),
|
|
3886
|
+
name: ref.title,
|
|
3887
|
+
title: ref.title,
|
|
3888
|
+
description: `"${ref.title}" (article ${ref.id}) — promoted Help Center article, as Markdown.`,
|
|
3889
|
+
mimeType: "text/markdown"
|
|
3890
|
+
})) };
|
|
3891
|
+
} catch (err) {
|
|
3892
|
+
logger.warn("article_resources_list_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
3893
|
+
return { resources: [] };
|
|
3894
|
+
}
|
|
3895
|
+
} });
|
|
3896
|
+
registered.push(server.registerResource("help-center-article", template, {
|
|
3897
|
+
title: "Zendesk Help Center article",
|
|
3898
|
+
description: "A Help Center article rendered as Markdown, addressed by id. The list surfaces the promoted (featured) articles so one can be pinned as context; any article id can be read, subject to your Zendesk read permissions.",
|
|
3899
|
+
mimeType: "text/markdown"
|
|
3900
|
+
}, async (uri, variables) => {
|
|
3901
|
+
const raw = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
|
|
3902
|
+
const id = Number(raw);
|
|
3903
|
+
if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid article id in resource URI: ${uri.toString()}`);
|
|
3904
|
+
return { contents: [{
|
|
3905
|
+
uri: uri.toString(),
|
|
3906
|
+
mimeType: "text/markdown",
|
|
3907
|
+
text: await articles.readArticle(id)
|
|
3908
|
+
}] };
|
|
3909
|
+
}));
|
|
3910
|
+
}
|
|
3579
3911
|
} catch (err) {
|
|
3580
3912
|
dispose();
|
|
3581
3913
|
throw err;
|
|
@@ -3751,6 +4083,7 @@ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
|
3751
4083
|
"::",
|
|
3752
4084
|
"*"
|
|
3753
4085
|
]);
|
|
4086
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
3754
4087
|
const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
|
|
3755
4088
|
"https://chatgpt.com",
|
|
3756
4089
|
"https://chat.openai.com",
|
|
@@ -3826,7 +4159,7 @@ const handleCorsPreflight = (req, res, extraOrigins) => {
|
|
|
3826
4159
|
return true;
|
|
3827
4160
|
};
|
|
3828
4161
|
const resolveResourceUrl = (config, logger = silentLogger) => {
|
|
3829
|
-
if (config.publicUrl) return config.publicUrl.replace(
|
|
4162
|
+
if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
|
|
3830
4163
|
if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
|
|
3831
4164
|
logger.warn("public_url_unset", {
|
|
3832
4165
|
host: config.host,
|
|
@@ -3883,6 +4216,14 @@ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
|
|
|
3883
4216
|
jsonrpc: "2.0"
|
|
3884
4217
|
}));
|
|
3885
4218
|
};
|
|
4219
|
+
const failRequest = (res, err) => {
|
|
4220
|
+
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
4221
|
+
if (!res.headersSent) {
|
|
4222
|
+
sendJsonRpcError(res, 500, -32603, message);
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
if (!res.writableEnded) res.end();
|
|
4226
|
+
};
|
|
3886
4227
|
const sendUnauthorized = (res, resource) => {
|
|
3887
4228
|
const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
|
|
3888
4229
|
sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
|
|
@@ -3953,6 +4294,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
3953
4294
|
const sessions = /* @__PURE__ */ new Map();
|
|
3954
4295
|
const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
|
|
3955
4296
|
const maxBodyBytes = options.maxBodyBytes ?? 4194304;
|
|
4297
|
+
const dispatchToSession = async (req, res, sessionId, bearer) => {
|
|
4298
|
+
const session = sessions.get(sessionId);
|
|
4299
|
+
if (!session) return false;
|
|
4300
|
+
session.auth.bearer = bearer;
|
|
4301
|
+
session.lastActivityAt = Date.now();
|
|
4302
|
+
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
4303
|
+
ok: true,
|
|
4304
|
+
value: void 0
|
|
4305
|
+
};
|
|
4306
|
+
if (!body.ok) {
|
|
4307
|
+
respondBodyError(req, res, body);
|
|
4308
|
+
return true;
|
|
4309
|
+
}
|
|
4310
|
+
await session.transport.handleRequest(req, res, body.value);
|
|
4311
|
+
return true;
|
|
4312
|
+
};
|
|
3956
4313
|
const handleMcpRequest = async (req, res) => {
|
|
3957
4314
|
const bearer = extractBearer(req);
|
|
3958
4315
|
if (!bearer) {
|
|
@@ -3960,23 +4317,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
3960
4317
|
return;
|
|
3961
4318
|
}
|
|
3962
4319
|
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
|
|
3963
|
-
if (sessionId)
|
|
3964
|
-
const session = sessions.get(sessionId);
|
|
3965
|
-
if (session) {
|
|
3966
|
-
session.auth.bearer = bearer;
|
|
3967
|
-
session.lastActivityAt = Date.now();
|
|
3968
|
-
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
3969
|
-
ok: true,
|
|
3970
|
-
value: void 0
|
|
3971
|
-
};
|
|
3972
|
-
if (!body.ok) {
|
|
3973
|
-
respondBodyError(req, res, body);
|
|
3974
|
-
return;
|
|
3975
|
-
}
|
|
3976
|
-
await session.transport.handleRequest(req, res, body.value);
|
|
3977
|
-
return;
|
|
3978
|
-
}
|
|
3979
|
-
}
|
|
4320
|
+
if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
|
|
3980
4321
|
if (req.method !== "POST") {
|
|
3981
4322
|
sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
|
|
3982
4323
|
return;
|
|
@@ -4008,24 +4349,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4008
4349
|
await server.connect(transport);
|
|
4009
4350
|
await transport.handleRequest(req, res, body.value);
|
|
4010
4351
|
};
|
|
4352
|
+
const staticGetRoutes = {
|
|
4353
|
+
"/.well-known/oauth-protected-resource": metadata.protectedResource,
|
|
4354
|
+
"/.well-known/oauth-authorization-server": metadata.authorizationServer,
|
|
4355
|
+
"/healthz": {
|
|
4356
|
+
status: "ok",
|
|
4357
|
+
subdomain: config.subdomain
|
|
4358
|
+
}
|
|
4359
|
+
};
|
|
4011
4360
|
const requestListener = async (req, res) => {
|
|
4012
4361
|
try {
|
|
4013
4362
|
if (handleCorsPreflight(req, res, config.corsOrigins)) return;
|
|
4014
4363
|
applyCorsHeaders(req, res, config.corsOrigins);
|
|
4015
4364
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
}
|
|
4020
|
-
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
4021
|
-
sendJson(res, 200, metadata.authorizationServer);
|
|
4022
|
-
return;
|
|
4023
|
-
}
|
|
4024
|
-
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
4025
|
-
sendJson(res, 200, {
|
|
4026
|
-
status: "ok",
|
|
4027
|
-
subdomain: config.subdomain
|
|
4028
|
-
});
|
|
4365
|
+
const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
|
|
4366
|
+
if (staticRoute) {
|
|
4367
|
+
sendJson(res, 200, staticRoute);
|
|
4029
4368
|
return;
|
|
4030
4369
|
}
|
|
4031
4370
|
if (url.pathname === "/mcp") {
|
|
@@ -4038,9 +4377,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4038
4377
|
path: url.pathname
|
|
4039
4378
|
}));
|
|
4040
4379
|
} catch (err) {
|
|
4041
|
-
|
|
4042
|
-
if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
|
|
4043
|
-
else if (!res.writableEnded) res.end();
|
|
4380
|
+
failRequest(res, err);
|
|
4044
4381
|
}
|
|
4045
4382
|
};
|
|
4046
4383
|
const httpServer = createServer((req, res) => {
|
|
@@ -4117,5 +4454,3 @@ main().catch((error) => {
|
|
|
4117
4454
|
});
|
|
4118
4455
|
//#endregion
|
|
4119
4456
|
export {};
|
|
4120
|
-
|
|
4121
|
-
//# sourceMappingURL=index.js.map
|