@fruggr/zendesk-mcp-server 2.16.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -288
- package/dist/index.js +701 -299
- package/package.json +11 -7
- package/dist/index.d.ts +0 -2
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -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],
|
|
@@ -115,7 +125,8 @@ const positiveIntEnv = (name, fallback) => {
|
|
|
115
125
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
116
126
|
};
|
|
117
127
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
118
|
-
const
|
|
128
|
+
const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
|
|
129
|
+
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
|
|
119
130
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
120
131
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
121
132
|
const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
|
|
@@ -127,7 +138,7 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
127
138
|
});
|
|
128
139
|
//#endregion
|
|
129
140
|
//#region src/auth/browser-oauth.ts
|
|
130
|
-
const AUTH_TIMEOUT_MS =
|
|
141
|
+
const AUTH_TIMEOUT_MS = 3e5;
|
|
131
142
|
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
132
143
|
const detectWsl = () => {
|
|
133
144
|
if (process.platform !== "linux") return false;
|
|
@@ -154,6 +165,8 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
|
|
|
154
165
|
* browser response; without escaping these are a reflected-XSS sink.
|
|
155
166
|
*/
|
|
156
167
|
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
168
|
+
const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
|
|
169
|
+
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>";
|
|
157
170
|
const generateCodeVerifier = () => randomBytes(32).toString("base64url");
|
|
158
171
|
const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
159
172
|
/**
|
|
@@ -179,69 +192,92 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
179
192
|
});
|
|
180
193
|
let authTimeout;
|
|
181
194
|
let callbackServer;
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
195
|
+
const exchangeCodeForToken = async (code) => {
|
|
196
|
+
const callbackPort = callbackServer.address().port;
|
|
197
|
+
const tokenBody = new URLSearchParams({
|
|
198
|
+
grant_type: "authorization_code",
|
|
199
|
+
code,
|
|
200
|
+
client_id: oauthClientId,
|
|
201
|
+
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
202
|
+
code_verifier: codeVerifier
|
|
203
|
+
});
|
|
204
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
207
|
+
body: tokenBody.toString()
|
|
208
|
+
});
|
|
209
|
+
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
210
|
+
if (!tokenResponse.ok) {
|
|
211
|
+
const errorBody = await tokenResponse.text();
|
|
212
|
+
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
188
213
|
}
|
|
189
|
-
|
|
214
|
+
return await tokenResponse.json();
|
|
215
|
+
};
|
|
216
|
+
const finishRequest = (res, { status, html, outcome }) => {
|
|
217
|
+
res.writeHead(status, { "Content-Type": "text/html" });
|
|
218
|
+
res.end(html);
|
|
219
|
+
clearTimeout(authTimeout);
|
|
220
|
+
callbackServer.close();
|
|
221
|
+
if (outcome.ok) resolveToken(outcome.token);
|
|
222
|
+
else rejectToken(outcome.error);
|
|
223
|
+
};
|
|
224
|
+
const resolveCallback = async (url) => {
|
|
190
225
|
const error = url.searchParams.get("error");
|
|
226
|
+
const code = url.searchParams.get("code");
|
|
191
227
|
logger.debug("oauth_callback_received", {
|
|
192
228
|
hasCode: Boolean(code),
|
|
193
229
|
hasError: Boolean(error)
|
|
194
230
|
});
|
|
195
231
|
if (error) {
|
|
196
232
|
const desc = url.searchParams.get("error_description") ?? error;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
206
|
-
res.end("<html><body><h1>Missing authorization code</h1></body></html>");
|
|
207
|
-
clearTimeout(authTimeout);
|
|
208
|
-
callbackServer.close();
|
|
209
|
-
rejectToken(/* @__PURE__ */ new Error("Missing authorization code in callback"));
|
|
210
|
-
return;
|
|
233
|
+
return {
|
|
234
|
+
status: 400,
|
|
235
|
+
html: errorPage("Authentication failed", desc),
|
|
236
|
+
outcome: {
|
|
237
|
+
ok: false,
|
|
238
|
+
error: /* @__PURE__ */ new Error(`OAuth error: ${desc}`)
|
|
239
|
+
}
|
|
240
|
+
};
|
|
211
241
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
219
|
-
code_verifier: codeVerifier
|
|
220
|
-
});
|
|
221
|
-
const tokenResponse = await fetch(tokenUrl, {
|
|
222
|
-
method: "POST",
|
|
223
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
224
|
-
body: tokenBody.toString()
|
|
225
|
-
});
|
|
226
|
-
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
227
|
-
if (!tokenResponse.ok) {
|
|
228
|
-
const errorBody = await tokenResponse.text();
|
|
229
|
-
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
242
|
+
if (!code) return {
|
|
243
|
+
status: 400,
|
|
244
|
+
html: errorPage("Missing authorization code"),
|
|
245
|
+
outcome: {
|
|
246
|
+
ok: false,
|
|
247
|
+
error: /* @__PURE__ */ new Error("Missing authorization code in callback")
|
|
230
248
|
}
|
|
231
|
-
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
const token = await exchangeCodeForToken(code);
|
|
232
252
|
logger.info("oauth_authenticated");
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
return {
|
|
254
|
+
status: 200,
|
|
255
|
+
html: SUCCESS_PAGE,
|
|
256
|
+
outcome: {
|
|
257
|
+
ok: true,
|
|
258
|
+
token
|
|
259
|
+
}
|
|
260
|
+
};
|
|
238
261
|
} catch (err) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
262
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
263
|
+
return {
|
|
264
|
+
status: 500,
|
|
265
|
+
html: errorPage("Token exchange failed", detail),
|
|
266
|
+
outcome: {
|
|
267
|
+
ok: false,
|
|
268
|
+
error: err
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
callbackServer = createServer(async (req, res) => {
|
|
274
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
275
|
+
if (url.pathname !== "/callback") {
|
|
276
|
+
res.writeHead(404);
|
|
277
|
+
res.end("Not found");
|
|
278
|
+
return;
|
|
244
279
|
}
|
|
280
|
+
finishRequest(res, await resolveCallback(url));
|
|
245
281
|
});
|
|
246
282
|
const requestedPort = config.callbackPort ?? 27439;
|
|
247
283
|
const onStartError = (err) => {
|
|
@@ -384,15 +420,20 @@ const isWindows = process.platform === "win32";
|
|
|
384
420
|
* (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
|
|
385
421
|
* vendor-namespaced and can't collide with another `zendesk-mcp-server`.
|
|
386
422
|
*/
|
|
423
|
+
const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)$/;
|
|
387
424
|
const appDirSegments = () => {
|
|
388
425
|
const { name } = readPackageInfo();
|
|
389
|
-
const scoped =
|
|
426
|
+
const scoped = SCOPED_PACKAGE_NAME.exec(name);
|
|
390
427
|
return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
|
|
391
428
|
};
|
|
392
429
|
const configDir = () => {
|
|
393
430
|
const segments = appDirSegments();
|
|
394
|
-
if (isWindows)
|
|
395
|
-
|
|
431
|
+
if (isWindows) {
|
|
432
|
+
const base = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming");
|
|
433
|
+
return join(base, ...segments);
|
|
434
|
+
}
|
|
435
|
+
const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
|
436
|
+
return join(base, ...segments);
|
|
396
437
|
};
|
|
397
438
|
const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
|
|
398
439
|
/**
|
|
@@ -443,7 +484,7 @@ const clearToken = (path, logger = silentLogger) => {
|
|
|
443
484
|
//#endregion
|
|
444
485
|
//#region src/auth/token-store.ts
|
|
445
486
|
const EXPIRY_SKEW_MS = 6e4;
|
|
446
|
-
const SCHEDULED_REFRESH_MS =
|
|
487
|
+
const SCHEDULED_REFRESH_MS = 144e5;
|
|
447
488
|
const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
|
|
448
489
|
name: "AuthRequiredError",
|
|
449
490
|
authorizeUrl
|
|
@@ -522,20 +563,23 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
522
563
|
throw err;
|
|
523
564
|
});
|
|
524
565
|
};
|
|
566
|
+
const refreshIfPossible = async () => {
|
|
567
|
+
const current = token;
|
|
568
|
+
if (!current?.refreshToken) return void 0;
|
|
569
|
+
if (refreshing === void 0) refreshing = tryRefresh(current).finally(() => {
|
|
570
|
+
refreshing = void 0;
|
|
571
|
+
});
|
|
572
|
+
return refreshing;
|
|
573
|
+
};
|
|
525
574
|
const getToken = async () => {
|
|
526
|
-
if (refreshing) await refreshing;
|
|
575
|
+
if (refreshing !== void 0) await refreshing;
|
|
527
576
|
if (token && !needsRefresh(token)) {
|
|
528
577
|
logger.debug("oauth_token_cache_hit");
|
|
529
578
|
return token.accessToken;
|
|
530
579
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
535
|
-
const refreshed = await refreshing;
|
|
536
|
-
if (refreshed) return refreshed;
|
|
537
|
-
}
|
|
538
|
-
if (!starting) starting = beginAuth();
|
|
580
|
+
const refreshed = await refreshIfPossible();
|
|
581
|
+
if (refreshed) return refreshed;
|
|
582
|
+
if (starting === void 0) starting = beginAuth();
|
|
539
583
|
const url = authorizeUrl ?? await starting;
|
|
540
584
|
throw createAuthRequiredError(url);
|
|
541
585
|
};
|
|
@@ -657,76 +701,96 @@ const ConfigSchema = z.object({
|
|
|
657
701
|
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([]),
|
|
658
702
|
callbackPort: z.number().int().min(1).max(65535).optional()
|
|
659
703
|
});
|
|
704
|
+
const DIGITS_ONLY = /^\d+$/;
|
|
660
705
|
const parsePort = (raw, label) => {
|
|
661
|
-
if (
|
|
706
|
+
if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
|
|
662
707
|
return Number(raw);
|
|
663
708
|
};
|
|
664
709
|
const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
|
|
710
|
+
const appendTo = (key) => (result, value) => {
|
|
711
|
+
const list = result[key] ?? [];
|
|
712
|
+
list.push(value);
|
|
713
|
+
result[key] = list;
|
|
714
|
+
};
|
|
715
|
+
const STANDALONE_FLAGS = /* @__PURE__ */ new Map([
|
|
716
|
+
["--read-only", (result) => {
|
|
717
|
+
result.readOnly = true;
|
|
718
|
+
}],
|
|
719
|
+
["--no-topology", (result) => {
|
|
720
|
+
result.topology = false;
|
|
721
|
+
}],
|
|
722
|
+
["--no-promoted-articles", (result) => {
|
|
723
|
+
result.promotedArticles = false;
|
|
724
|
+
}],
|
|
725
|
+
["--dev", (result) => {
|
|
726
|
+
result.dev = true;
|
|
727
|
+
}]
|
|
728
|
+
]);
|
|
729
|
+
const VALUED_FLAGS = /* @__PURE__ */ new Map([
|
|
730
|
+
["--mode", (result, value) => {
|
|
731
|
+
result.mode = value;
|
|
732
|
+
}],
|
|
733
|
+
["--hc-resource-scheme", (result, value) => {
|
|
734
|
+
result.hcResourceScheme = value;
|
|
735
|
+
}],
|
|
736
|
+
["--log-level", (result, value) => {
|
|
737
|
+
result.logLevel = value;
|
|
738
|
+
}],
|
|
739
|
+
["--transport", (result, value) => {
|
|
740
|
+
result.transport = value;
|
|
741
|
+
}],
|
|
742
|
+
["--host", (result, value) => {
|
|
743
|
+
result.host = value;
|
|
744
|
+
}],
|
|
745
|
+
["--public-url", (result, value) => {
|
|
746
|
+
result.publicUrl = value;
|
|
747
|
+
}],
|
|
748
|
+
["--port", (result, value) => {
|
|
749
|
+
result.port = parsePort(value, "--port");
|
|
750
|
+
}],
|
|
751
|
+
["--callback-port", (result, value) => {
|
|
752
|
+
result.callbackPort = parsePort(value, "--callback-port");
|
|
753
|
+
}],
|
|
754
|
+
["--namespace", appendTo("namespaces")],
|
|
755
|
+
["--tool", appendTo("tools")],
|
|
756
|
+
["--cors-origin", appendTo("corsOrigins")]
|
|
757
|
+
]);
|
|
665
758
|
const parseCliArgs = (args) => {
|
|
666
759
|
const result = {};
|
|
667
|
-
let positionalIndex = 0;
|
|
668
760
|
for (let i = 0; i < args.length; i++) {
|
|
669
761
|
const arg = args[i];
|
|
670
762
|
if (arg === void 0) continue;
|
|
763
|
+
const standalone = STANDALONE_FLAGS.get(arg);
|
|
764
|
+
if (standalone) {
|
|
765
|
+
standalone(result);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const valued = VALUED_FLAGS.get(arg);
|
|
671
769
|
const next = args[i + 1];
|
|
672
|
-
if (
|
|
673
|
-
result
|
|
674
|
-
i++;
|
|
675
|
-
} else if (arg === "--read-only") result.readOnly = true;
|
|
676
|
-
else if (arg === "--no-topology") result.topology = false;
|
|
677
|
-
else if (arg === "--no-promoted-articles") result.promotedArticles = false;
|
|
678
|
-
else if (arg === "--hc-resource-scheme" && next) {
|
|
679
|
-
result.hcResourceScheme = next;
|
|
680
|
-
i++;
|
|
681
|
-
} else if (arg === "--dev") result.dev = true;
|
|
682
|
-
else if (arg === "--namespace" && next) {
|
|
683
|
-
result.namespaces = result.namespaces ?? [];
|
|
684
|
-
result.namespaces.push(next);
|
|
685
|
-
i++;
|
|
686
|
-
} else if (arg === "--tool" && next) {
|
|
687
|
-
result.tools = result.tools ?? [];
|
|
688
|
-
result.tools.push(next);
|
|
689
|
-
i++;
|
|
690
|
-
} else if (arg === "--log-level" && next) {
|
|
691
|
-
result.logLevel = next;
|
|
770
|
+
if (valued && next) {
|
|
771
|
+
valued(result, next);
|
|
692
772
|
i++;
|
|
693
|
-
|
|
694
|
-
result.transport = next;
|
|
695
|
-
i++;
|
|
696
|
-
} else if (arg === "--host" && next) {
|
|
697
|
-
result.host = next;
|
|
698
|
-
i++;
|
|
699
|
-
} else if (arg === "--port" && next) {
|
|
700
|
-
result.port = parsePort(next, "--port");
|
|
701
|
-
i++;
|
|
702
|
-
} else if (arg === "--public-url" && next) {
|
|
703
|
-
result.publicUrl = next;
|
|
704
|
-
i++;
|
|
705
|
-
} else if (arg === "--cors-origin" && next) {
|
|
706
|
-
result.corsOrigins = result.corsOrigins ?? [];
|
|
707
|
-
result.corsOrigins.push(next);
|
|
708
|
-
i++;
|
|
709
|
-
} else if (arg === "--callback-port" && next) {
|
|
710
|
-
result.callbackPort = parsePort(next, "--callback-port");
|
|
711
|
-
i++;
|
|
712
|
-
} else if (!arg.startsWith("-") && positionalIndex === 0) {
|
|
713
|
-
result.subdomain = arg;
|
|
714
|
-
positionalIndex++;
|
|
773
|
+
continue;
|
|
715
774
|
}
|
|
775
|
+
if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
|
|
716
776
|
}
|
|
717
777
|
return result;
|
|
718
778
|
};
|
|
779
|
+
const resolveTransportSettings = (cli) => {
|
|
780
|
+
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
781
|
+
return {
|
|
782
|
+
transport: cli.transport ?? process.env["TRANSPORT"] ?? "stdio",
|
|
783
|
+
host: cli.host ?? process.env["HOST"] ?? "0.0.0.0",
|
|
784
|
+
port: cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3,
|
|
785
|
+
publicUrl: cli.publicUrl ?? process.env["PUBLIC_URL"],
|
|
786
|
+
corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
|
|
787
|
+
};
|
|
788
|
+
};
|
|
719
789
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
720
790
|
const cli = parseCliArgs(argv);
|
|
721
791
|
const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
|
|
722
792
|
const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
|
|
723
793
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
724
|
-
const transport = cli.transport ?? process.env["TRANSPORT"] ?? "stdio";
|
|
725
|
-
const host = cli.host ?? process.env["HOST"] ?? "0.0.0.0";
|
|
726
|
-
const port = cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3;
|
|
727
|
-
const publicUrl = cli.publicUrl ?? process.env["PUBLIC_URL"];
|
|
728
|
-
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
729
|
-
const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
|
|
730
794
|
const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
731
795
|
const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
|
|
732
796
|
return ConfigSchema.parse({
|
|
@@ -741,12 +805,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
741
805
|
promotedArticles: cli.promotedArticles ?? true,
|
|
742
806
|
hcResourceScheme,
|
|
743
807
|
dev: cli.dev ?? false,
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
port,
|
|
747
|
-
publicUrl,
|
|
748
|
-
corsOrigins,
|
|
749
|
-
callbackPort
|
|
808
|
+
callbackPort,
|
|
809
|
+
...resolveTransportSettings(cli)
|
|
750
810
|
});
|
|
751
811
|
};
|
|
752
812
|
//#endregion
|
|
@@ -893,10 +953,11 @@ const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
|
893
953
|
"h2",
|
|
894
954
|
"h3"
|
|
895
955
|
]);
|
|
956
|
+
const WHITESPACE_RUN = /\s+/;
|
|
896
957
|
const countWords = (text) => {
|
|
897
958
|
const trimmed = text.trim();
|
|
898
959
|
if (!trimmed) return 0;
|
|
899
|
-
return trimmed.split(
|
|
960
|
+
return trimmed.split(WHITESPACE_RUN).length;
|
|
900
961
|
};
|
|
901
962
|
const textOf = (html) => {
|
|
902
963
|
if (!html) return "";
|
|
@@ -1000,7 +1061,11 @@ const formatTicket = (ticket) => [
|
|
|
1000
1061
|
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
1001
1062
|
ticket.description ? `\n${ticket.description}` : ""
|
|
1002
1063
|
].filter(Boolean).join("\n");
|
|
1003
|
-
const formatConditionValue = (value) =>
|
|
1064
|
+
const formatConditionValue = (value) => {
|
|
1065
|
+
if (value === null || value === void 0) return "";
|
|
1066
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1067
|
+
return String(value);
|
|
1068
|
+
};
|
|
1004
1069
|
const formatSlaPolicy = (policy) => {
|
|
1005
1070
|
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())];
|
|
1006
1071
|
const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
|
|
@@ -1199,6 +1264,13 @@ const formatTranslation = (translation) => [
|
|
|
1199
1264
|
"",
|
|
1200
1265
|
translation.body
|
|
1201
1266
|
].join("\n");
|
|
1267
|
+
const formatNodeTranslationSummary = (translation) => [
|
|
1268
|
+
`## Translation: ${translation.locale} (${translation.id})`,
|
|
1269
|
+
`- **Name**: ${translation.title}`,
|
|
1270
|
+
`- **Description**: ${translation.body ? "set" : "empty"}`,
|
|
1271
|
+
`- **Draft**: ${translation.draft}`,
|
|
1272
|
+
`- **Updated**: ${translation.updated_at}`
|
|
1273
|
+
].join("\n");
|
|
1202
1274
|
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
1203
1275
|
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
1204
1276
|
const formatView = (view, count) => {
|
|
@@ -1299,12 +1371,14 @@ const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOUR
|
|
|
1299
1371
|
* character limit. Reuses the same formatting as the `get_article` tool.
|
|
1300
1372
|
*/
|
|
1301
1373
|
const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1374
|
+
const path = locale ? `/${locale}/articles/${id}` : `/articles/${id}`;
|
|
1375
|
+
const { article } = await helpCenterGet(subdomain, token, path);
|
|
1376
|
+
const text = [
|
|
1304
1377
|
formatArticleSummary(article),
|
|
1305
1378
|
"",
|
|
1306
1379
|
htmlToMarkdown(article.body)
|
|
1307
|
-
].join("\n")
|
|
1380
|
+
].join("\n");
|
|
1381
|
+
return truncateIfNeeded(text);
|
|
1308
1382
|
};
|
|
1309
1383
|
/**
|
|
1310
1384
|
* Build an article-resources provider. `listPromoted` holds a memoized-promise
|
|
@@ -1493,20 +1567,20 @@ const fetchTopology = async (subdomain, token) => {
|
|
|
1493
1567
|
currentUser: meRes.user
|
|
1494
1568
|
};
|
|
1495
1569
|
};
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1570
|
+
const renderOversizedTreeNotice = (data) => {
|
|
1571
|
+
const reasons = [];
|
|
1572
|
+
if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
|
|
1573
|
+
if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
|
|
1574
|
+
return [
|
|
1575
|
+
`Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
|
|
1576
|
+
data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
|
|
1577
|
+
...data.categories.map(formatCategory),
|
|
1578
|
+
"",
|
|
1579
|
+
...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
|
|
1580
|
+
"Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
|
|
1581
|
+
];
|
|
1582
|
+
};
|
|
1583
|
+
const renderCategoryTree = (data) => {
|
|
1510
1584
|
const byCategory = /* @__PURE__ */ new Map();
|
|
1511
1585
|
for (const section of data.sections) {
|
|
1512
1586
|
const list = byCategory.get(section.category_id) ?? [];
|
|
@@ -1520,6 +1594,7 @@ const renderTree = (data) => {
|
|
|
1520
1594
|
}
|
|
1521
1595
|
return lines.length ? lines : ["_(no categories)_"];
|
|
1522
1596
|
};
|
|
1597
|
+
const renderTree = (data) => data.categoriesHasMore || data.sectionsHasMore ? renderOversizedTreeNotice(data) : renderCategoryTree(data);
|
|
1523
1598
|
/**
|
|
1524
1599
|
* Render an admin-gated section as one of three states so the LLM never mistakes
|
|
1525
1600
|
* "you can't see this" for "there are none": the formatted list, `_(none)_` when
|
|
@@ -1531,7 +1606,7 @@ const renderAdminSection = (items, denied, deniedNote) => {
|
|
|
1531
1606
|
};
|
|
1532
1607
|
/** Render the topology as a compact Markdown document for the LLM context. */
|
|
1533
1608
|
const formatTopology = (data) => {
|
|
1534
|
-
|
|
1609
|
+
const text = [
|
|
1535
1610
|
`# Zendesk Help Center topology — ${data.subdomain}`,
|
|
1536
1611
|
"",
|
|
1537
1612
|
`**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
|
|
@@ -1548,7 +1623,8 @@ const formatTopology = (data) => {
|
|
|
1548
1623
|
"",
|
|
1549
1624
|
"## Permission groups",
|
|
1550
1625
|
...renderAdminSection(data.permissionGroups.map(formatPermissionGroup), data.permissionGroupsDenied, "_Unavailable: listing permission groups requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To create or edit an article, reuse the permission_group_id of an existing article (get_article)._")
|
|
1551
|
-
].join("\n")
|
|
1626
|
+
].join("\n");
|
|
1627
|
+
return truncateIfNeeded(text);
|
|
1552
1628
|
};
|
|
1553
1629
|
/**
|
|
1554
1630
|
* Build a topology provider holding a memoized-promise cache (TTL
|
|
@@ -1676,6 +1752,117 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
|
|
|
1676
1752
|
//#region src/tools/help-center.ts
|
|
1677
1753
|
const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
|
|
1678
1754
|
const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
|
|
1755
|
+
const NODE_LABEL = {
|
|
1756
|
+
sections: "section",
|
|
1757
|
+
categories: "category"
|
|
1758
|
+
};
|
|
1759
|
+
const listNodeTranslations = (subdomain, token, kind, nodeId, locale) => helpCenterGet(subdomain, token, `/${kind}/${nodeId}/translations`, locale ? { locales: locale.toLowerCase() } : void 0).then((res) => res.translations ?? []);
|
|
1760
|
+
const findTranslation = (translations, locale) => {
|
|
1761
|
+
const wanted = locale.toLowerCase();
|
|
1762
|
+
return translations.find((t) => t.locale.toLowerCase() === wanted);
|
|
1763
|
+
};
|
|
1764
|
+
/**
|
|
1765
|
+
* Create-or-update a section/category translation in one call. The POST-vs-PUT
|
|
1766
|
+
* probe spares the caller a listing round-trip, or the 400 a duplicate POST
|
|
1767
|
+
* returns. Only the fields passed are sent on update, so omitting `description`
|
|
1768
|
+
* never blanks it and omitting `draft` never (un)publishes by accident.
|
|
1769
|
+
*/
|
|
1770
|
+
const upsertNodeTranslation = async (subdomain, token, kind, nodeId, input) => {
|
|
1771
|
+
const { locale, name, description, draft } = input;
|
|
1772
|
+
const existing = findTranslation(await listNodeTranslations(subdomain, token, kind, nodeId, locale), locale);
|
|
1773
|
+
if (!existing) {
|
|
1774
|
+
if (name === void 0) throw new Error(`${NODE_LABEL[kind]} #${nodeId} has no "${locale}" translation yet, so one has to be created and "name" is required. Pass the localized name, or call list_${NODE_LABEL[kind]}_translations to see which locales already exist.`);
|
|
1775
|
+
const { translation } = await helpCenterPost(subdomain, token, `/${kind}/${nodeId}/translations`, { translation: {
|
|
1776
|
+
locale,
|
|
1777
|
+
title: name,
|
|
1778
|
+
body: description ?? "",
|
|
1779
|
+
draft: draft ?? false
|
|
1780
|
+
} });
|
|
1781
|
+
return {
|
|
1782
|
+
translation,
|
|
1783
|
+
created: true
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
const updates = {};
|
|
1787
|
+
if (name !== void 0) updates["title"] = name;
|
|
1788
|
+
if (description !== void 0) updates["body"] = description;
|
|
1789
|
+
if (draft !== void 0) updates["draft"] = draft;
|
|
1790
|
+
if (Object.keys(updates).length === 0) throw new Error(`Nothing to write: ${NODE_LABEL[kind]} #${nodeId} already has a "${existing.locale}" translation, so pass at least one of "name", "description" or "draft" to change it (draft: false publishes it).`);
|
|
1791
|
+
const { translation } = await helpCenterPut(subdomain, token, `/${kind}/${nodeId}/translations/${existing.locale}`, { translation: updates });
|
|
1792
|
+
return {
|
|
1793
|
+
translation,
|
|
1794
|
+
created: false
|
|
1795
|
+
};
|
|
1796
|
+
};
|
|
1797
|
+
const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
|
|
1798
|
+
`Translation ${created ? "created" : "updated"} for ${NODE_LABEL[kind]} #${nodeId} in "${translation.locale}" (${translation.draft ? "draft, not visible to end users" : "published"}).`,
|
|
1799
|
+
"",
|
|
1800
|
+
formatNodeTranslationSummary(translation)
|
|
1801
|
+
].join("\n");
|
|
1802
|
+
const GAP_REASON_TEXT = {
|
|
1803
|
+
missing: "no translation",
|
|
1804
|
+
draft: "draft translation (not published)"
|
|
1805
|
+
};
|
|
1806
|
+
const classifyGap = (node, translations, locale) => {
|
|
1807
|
+
const translation = findTranslation(translations, locale);
|
|
1808
|
+
if (!translation) return {
|
|
1809
|
+
id: node.id,
|
|
1810
|
+
name: node.name,
|
|
1811
|
+
reason: "missing"
|
|
1812
|
+
};
|
|
1813
|
+
return translation.draft ? {
|
|
1814
|
+
id: node.id,
|
|
1815
|
+
name: node.name,
|
|
1816
|
+
reason: "draft"
|
|
1817
|
+
} : null;
|
|
1818
|
+
};
|
|
1819
|
+
const renderGapLines = (heading, gaps, scanned, found) => {
|
|
1820
|
+
const header = `## ${heading} (${scanned} scanned)`;
|
|
1821
|
+
if (gaps.length > 0) return [header, ...gaps.map((gap) => `- **${gap.name}** (${gap.id}) — ${GAP_REASON_TEXT[gap.reason]}`)];
|
|
1822
|
+
if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none scanned — the ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap was spent before this level; ${found} left unchecked, see the note below)_`];
|
|
1823
|
+
return [header, "_(none — every one scanned has a published translation)_"];
|
|
1824
|
+
};
|
|
1825
|
+
const GAP_SCAN_WAVE_SIZE = 5;
|
|
1826
|
+
const probeInWaves = async (nodes, probe) => {
|
|
1827
|
+
const gaps = [];
|
|
1828
|
+
for (let i = 0; i < nodes.length; i += GAP_SCAN_WAVE_SIZE) {
|
|
1829
|
+
const wave = await Promise.all(nodes.slice(i, i + GAP_SCAN_WAVE_SIZE).map(probe));
|
|
1830
|
+
for (const gap of wave) if (gap !== null) gaps.push(gap);
|
|
1831
|
+
}
|
|
1832
|
+
return gaps;
|
|
1833
|
+
};
|
|
1834
|
+
const fetchGapCategories = async (subdomain, token, categoryId) => {
|
|
1835
|
+
if (categoryId !== void 0) {
|
|
1836
|
+
const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`);
|
|
1837
|
+
return {
|
|
1838
|
+
categories: [category],
|
|
1839
|
+
hasMore: false
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
const response = await helpCenterGet(subdomain, token, "/categories", buildCursorParams(100, void 0));
|
|
1843
|
+
const categories = response.categories ?? [];
|
|
1844
|
+
return {
|
|
1845
|
+
categories,
|
|
1846
|
+
hasMore: extractPaginationMeta(response, categories.length).has_more
|
|
1847
|
+
};
|
|
1848
|
+
};
|
|
1849
|
+
const renderGapReport = (report) => {
|
|
1850
|
+
const { locale, categoryGaps, sectionGaps, scanned, found } = report;
|
|
1851
|
+
const gapCount = categoryGaps.length + sectionGaps.length;
|
|
1852
|
+
const capped = scanned.categories < found.categories || scanned.sections < found.sections;
|
|
1853
|
+
return truncateIfNeeded([
|
|
1854
|
+
`# Translation gaps — "${locale}"`,
|
|
1855
|
+
"",
|
|
1856
|
+
...report.activeLocales.some((l) => l.toLowerCase() === locale.toLowerCase()) ? [] : [`> ⚠ "${locale}" is not an active locale of this Help Center (active: ${report.activeLocales.join(", ")}), so everything below reads as untranslated. Check the spelling, or activate the language in Guide first.`, ""],
|
|
1857
|
+
...renderGapLines("Categories", categoryGaps, scanned.categories, found.categories),
|
|
1858
|
+
"",
|
|
1859
|
+
...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
|
|
1860
|
+
"",
|
|
1861
|
+
gapCount === 0 ? `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.` : `${gapCount} node(s) need a published "${locale}" translation. Fix a category with set_category_translation and a section with set_section_translation, passing draft: false to publish.`,
|
|
1862
|
+
...capped ? ["", `_Note: the scan stopped at its ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap, covering ${scanned.categories}/${found.categories} categories and ${scanned.sections}/${found.sections} sections. The rest were not checked — narrow the scan with category_id, or raise ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES._`] : [],
|
|
1863
|
+
...report.listingIncomplete ? ["", `_Note: this Help Center has more than 100 categories or sections, so only the first page of each was considered. Narrow the scan with category_id to audit the rest._`] : []
|
|
1864
|
+
].join("\n"));
|
|
1865
|
+
};
|
|
1679
1866
|
const largeArticleHint = (body, sectionCount) => {
|
|
1680
1867
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
1681
1868
|
return [
|
|
@@ -1686,6 +1873,60 @@ const largeArticleHint = (body, sectionCount) => {
|
|
|
1686
1873
|
].join("\n");
|
|
1687
1874
|
};
|
|
1688
1875
|
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(" ");
|
|
1876
|
+
const renderFreshnessLine = (sourceUpdatedAt, targetUpdatedAt, targetLocale) => {
|
|
1877
|
+
const srcMs = Date.parse(sourceUpdatedAt);
|
|
1878
|
+
const tgtMs = Date.parse(targetUpdatedAt);
|
|
1879
|
+
const label = `- **Freshness (target ${targetLocale})**`;
|
|
1880
|
+
if (!Number.isFinite(srcMs) || !Number.isFinite(tgtMs)) return `${label}: unknown (could not compare edit timestamps).`;
|
|
1881
|
+
if (srcMs <= tgtMs) return `${label}: up to date (source has not been edited since this translation).`;
|
|
1882
|
+
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
1883
|
+
return `${label}: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
1884
|
+
};
|
|
1885
|
+
const renderOutdatedLine = (translations, targetLocale) => {
|
|
1886
|
+
const targetLocaleKey = targetLocale.toLowerCase();
|
|
1887
|
+
const entry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
1888
|
+
const label = `- **Zendesk outdated flag (target ${targetLocale})**`;
|
|
1889
|
+
if (entry?.outdated === void 0) return `${label}: unknown.`;
|
|
1890
|
+
if (entry.outdated) return `${label}: yes — explicitly marked out of date in Guide.`;
|
|
1891
|
+
return `${label}: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).`;
|
|
1892
|
+
};
|
|
1893
|
+
const renderStructureLine = (sourceSections, targetSections) => {
|
|
1894
|
+
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
1895
|
+
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
1896
|
+
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.`;
|
|
1897
|
+
};
|
|
1898
|
+
const sectionRowStatus = (source, target) => {
|
|
1899
|
+
if (!target) return "missing";
|
|
1900
|
+
if (!source) return "extra";
|
|
1901
|
+
return "ok";
|
|
1902
|
+
};
|
|
1903
|
+
const renderSectionRows = (sourceSections, targetSections) => {
|
|
1904
|
+
const rows = ["| Idx | Heading | Status | Source words | Target words |", "| --- | --- | --- | --- | --- |"];
|
|
1905
|
+
const maxLen = Math.max(sourceSections.length, targetSections.length);
|
|
1906
|
+
for (let i = 0; i < maxLen; i += 1) {
|
|
1907
|
+
const src = sourceSections[i];
|
|
1908
|
+
const tgt = targetSections[i];
|
|
1909
|
+
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
1910
|
+
const status = sectionRowStatus(src, tgt);
|
|
1911
|
+
rows.push(`| ${i} | ${heading} | ${status} | ${src?.wordCount ?? 0} | ${tgt?.wordCount ?? 0} |`);
|
|
1912
|
+
}
|
|
1913
|
+
return rows;
|
|
1914
|
+
};
|
|
1915
|
+
const localePrefix = (locale) => locale ? `/${locale}` : "";
|
|
1916
|
+
const articleListPath = (sectionId, locale) => `${localePrefix(locale)}${sectionId ? `/sections/${sectionId}` : ""}/articles`;
|
|
1917
|
+
const sectionListPath = (categoryId, locale) => `${localePrefix(locale)}${categoryId ? `/categories/${categoryId}` : ""}/sections`;
|
|
1918
|
+
const scanCostNote = (truncated, pagesScanned, cost) => {
|
|
1919
|
+
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._`;
|
|
1920
|
+
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._`;
|
|
1921
|
+
return "";
|
|
1922
|
+
};
|
|
1923
|
+
const needsReferenceArticle = (target) => target === "before" || target === "after";
|
|
1924
|
+
const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
|
|
1925
|
+
const needsReference = needsReferenceArticle(target);
|
|
1926
|
+
if (needsReference && referenceArticleId === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
|
|
1927
|
+
if (!needsReference && referenceArticleId !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
1928
|
+
if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
|
|
1929
|
+
};
|
|
1689
1930
|
const createHelpCenterTools = (ctx) => {
|
|
1690
1931
|
const { subdomain, getToken } = ctx;
|
|
1691
1932
|
const fetchSectionOrder = async (sectionId, locale, token) => {
|
|
@@ -1703,6 +1944,26 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1703
1944
|
} while (cursor);
|
|
1704
1945
|
return order;
|
|
1705
1946
|
};
|
|
1947
|
+
const assertReferenceInSection = async (effective, referenceArticleId, articleId, sectionId, token) => {
|
|
1948
|
+
if (effective.some((a) => a.id === referenceArticleId)) return;
|
|
1949
|
+
let detail = "was not found";
|
|
1950
|
+
try {
|
|
1951
|
+
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${referenceArticleId}`);
|
|
1952
|
+
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
1953
|
+
} catch {}
|
|
1954
|
+
throw new Error(`Reference article #${referenceArticleId} ${detail}. It must be in the same section (#${sectionId}) as article #${articleId}.`);
|
|
1955
|
+
};
|
|
1956
|
+
const applyPositionWrites = async (writes, articleId, token) => {
|
|
1957
|
+
let applied = 0;
|
|
1958
|
+
for (const write of writes) try {
|
|
1959
|
+
await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
|
|
1960
|
+
applied += 1;
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1963
|
+
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 });
|
|
1964
|
+
}
|
|
1965
|
+
return applied;
|
|
1966
|
+
};
|
|
1706
1967
|
return [
|
|
1707
1968
|
{
|
|
1708
1969
|
name: "search_articles",
|
|
@@ -1759,9 +2020,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1759
2020
|
const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
|
|
1760
2021
|
const { article } = await helpCenterGet(subdomain, token, path);
|
|
1761
2022
|
const translations = await listTranslations(subdomain, token, article_id);
|
|
2023
|
+
const text = (largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
|
|
1762
2024
|
return { content: [{
|
|
1763
2025
|
type: "text",
|
|
1764
|
-
text: truncateIfNeeded(
|
|
2026
|
+
text: truncateIfNeeded(text)
|
|
1765
2027
|
}] };
|
|
1766
2028
|
}
|
|
1767
2029
|
},
|
|
@@ -1785,7 +2047,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1785
2047
|
handler: async (params) => {
|
|
1786
2048
|
const { locale, page_size, cursor } = params;
|
|
1787
2049
|
const token = await getToken();
|
|
1788
|
-
const path =
|
|
2050
|
+
const path = `${localePrefix(locale)}/categories`;
|
|
1789
2051
|
const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
|
|
1790
2052
|
const categories = response.categories ?? [];
|
|
1791
2053
|
return { content: [{
|
|
@@ -1815,8 +2077,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1815
2077
|
handler: async (params) => {
|
|
1816
2078
|
const { category_id, locale, page_size, cursor } = params;
|
|
1817
2079
|
const token = await getToken();
|
|
1818
|
-
const
|
|
1819
|
-
const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
|
|
2080
|
+
const response = await helpCenterGet(subdomain, token, sectionListPath(category_id, locale), buildCursorParams(page_size, cursor));
|
|
1820
2081
|
const sections = response.sections ?? [];
|
|
1821
2082
|
return { content: [{
|
|
1822
2083
|
type: "text",
|
|
@@ -1853,8 +2114,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1853
2114
|
handler: async (params) => {
|
|
1854
2115
|
const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
|
|
1855
2116
|
const token = await getToken();
|
|
1856
|
-
const
|
|
1857
|
-
const response = await helpCenterGet(subdomain, token, path, {
|
|
2117
|
+
const response = await helpCenterGet(subdomain, token, articleListPath(section_id, locale), {
|
|
1858
2118
|
...buildCursorParams(page_size, cursor),
|
|
1859
2119
|
sort_by,
|
|
1860
2120
|
sort_order
|
|
@@ -1869,9 +2129,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1869
2129
|
return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
|
|
1870
2130
|
}));
|
|
1871
2131
|
const meta = extractPaginationMeta(response, articles.length);
|
|
2132
|
+
const text = [meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n");
|
|
1872
2133
|
return { content: [{
|
|
1873
2134
|
type: "text",
|
|
1874
|
-
text: truncateIfNeeded(
|
|
2135
|
+
text: truncateIfNeeded(text)
|
|
1875
2136
|
}] };
|
|
1876
2137
|
}
|
|
1877
2138
|
},
|
|
@@ -1894,9 +2155,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1894
2155
|
const header = `Promoted (featured) articles: ${articles.length}`;
|
|
1895
2156
|
const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
|
|
1896
2157
|
const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
|
|
2158
|
+
const note = scanCostNote(truncated, pagesScanned, cost);
|
|
1897
2159
|
return { content: [{
|
|
1898
2160
|
type: "text",
|
|
1899
|
-
text: truncateIfNeeded(`${header}\n\n${body}${
|
|
2161
|
+
text: truncateIfNeeded(`${header}\n\n${body}${note}`)
|
|
1900
2162
|
}] };
|
|
1901
2163
|
}
|
|
1902
2164
|
},
|
|
@@ -1916,9 +2178,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1916
2178
|
handler: async (params) => {
|
|
1917
2179
|
const { article_id } = params;
|
|
1918
2180
|
const token = await getToken();
|
|
2181
|
+
const translations = await listTranslations(subdomain, token, article_id);
|
|
1919
2182
|
return { content: [{
|
|
1920
2183
|
type: "text",
|
|
1921
|
-
text: formatList(
|
|
2184
|
+
text: formatList(translations, formatTranslationSummary)
|
|
1922
2185
|
}] };
|
|
1923
2186
|
}
|
|
1924
2187
|
},
|
|
@@ -1985,6 +2248,160 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1985
2248
|
}] };
|
|
1986
2249
|
}
|
|
1987
2250
|
},
|
|
2251
|
+
{
|
|
2252
|
+
name: "list_section_translations",
|
|
2253
|
+
namespace: "help_center",
|
|
2254
|
+
readOnly: true,
|
|
2255
|
+
title: "List Section Translations",
|
|
2256
|
+
description: "List the translations of a Help Center section: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a section looks wrong in a locale, because list_sections with that locale cannot settle it: a section with no translation is omitted from it, while a section whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_section_translation; to sweep every category and section at once, use find_translation_gaps.",
|
|
2257
|
+
inputSchema: z.object({ section_id: z.number().int().describe("Section ID — the numeric id of the Help Center section. Obtain it from list_sections or the zendesk-hc://topology resource.") }),
|
|
2258
|
+
annotations: {
|
|
2259
|
+
readOnlyHint: true,
|
|
2260
|
+
destructiveHint: false,
|
|
2261
|
+
idempotentHint: true,
|
|
2262
|
+
openWorldHint: true
|
|
2263
|
+
},
|
|
2264
|
+
handler: async (params) => {
|
|
2265
|
+
const { section_id } = params;
|
|
2266
|
+
const token = await getToken();
|
|
2267
|
+
const translations = await listNodeTranslations(subdomain, token, "sections", section_id);
|
|
2268
|
+
return { content: [{
|
|
2269
|
+
type: "text",
|
|
2270
|
+
text: formatList(translations, formatNodeTranslationSummary)
|
|
2271
|
+
}] };
|
|
2272
|
+
}
|
|
2273
|
+
},
|
|
2274
|
+
{
|
|
2275
|
+
name: "list_category_translations",
|
|
2276
|
+
namespace: "help_center",
|
|
2277
|
+
readOnly: true,
|
|
2278
|
+
title: "List Category Translations",
|
|
2279
|
+
description: "List the translations of a Help Center category: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a category looks wrong in a locale, because list_categories with that locale cannot settle it: a category with no translation is omitted from it, while a category whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_category_translation; to sweep every category and section at once, use find_translation_gaps.",
|
|
2280
|
+
inputSchema: z.object({ category_id: z.number().int().describe("Category ID — the numeric id of the Help Center category. Obtain it from list_categories or the zendesk-hc://topology resource.") }),
|
|
2281
|
+
annotations: {
|
|
2282
|
+
readOnlyHint: true,
|
|
2283
|
+
destructiveHint: false,
|
|
2284
|
+
idempotentHint: true,
|
|
2285
|
+
openWorldHint: true
|
|
2286
|
+
},
|
|
2287
|
+
handler: async (params) => {
|
|
2288
|
+
const { category_id } = params;
|
|
2289
|
+
const token = await getToken();
|
|
2290
|
+
const translations = await listNodeTranslations(subdomain, token, "categories", category_id);
|
|
2291
|
+
return { content: [{
|
|
2292
|
+
type: "text",
|
|
2293
|
+
text: formatList(translations, formatNodeTranslationSummary)
|
|
2294
|
+
}] };
|
|
2295
|
+
}
|
|
2296
|
+
},
|
|
2297
|
+
{
|
|
2298
|
+
name: "find_translation_gaps",
|
|
2299
|
+
namespace: "help_center",
|
|
2300
|
+
readOnly: true,
|
|
2301
|
+
title: "Find Help Center Translation Gaps",
|
|
2302
|
+
description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs one extra request per node scanned, capped (the report says so when the cap bites) — pass category_id to narrow it. Fix what it reports with set_section_translation / set_category_translation.",
|
|
2303
|
+
inputSchema: z.object({
|
|
2304
|
+
locale: z.string().describe("Locale to audit, e.g. \"fr\" or \"de\" — usually a non-default active locale of the Help Center (zendesk-hc://topology lists them). A locale that is not active is reported as a warning, since every node would then look untranslated."),
|
|
2305
|
+
category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs one request per category and per section.")
|
|
2306
|
+
}),
|
|
2307
|
+
annotations: {
|
|
2308
|
+
readOnlyHint: true,
|
|
2309
|
+
destructiveHint: false,
|
|
2310
|
+
idempotentHint: true,
|
|
2311
|
+
openWorldHint: true
|
|
2312
|
+
},
|
|
2313
|
+
handler: async (params) => {
|
|
2314
|
+
const { locale, category_id } = params;
|
|
2315
|
+
const token = await getToken();
|
|
2316
|
+
const [locales, categoryScope, sectionsRes] = await Promise.all([
|
|
2317
|
+
helpCenterGet(subdomain, token, "/locales"),
|
|
2318
|
+
fetchGapCategories(subdomain, token, category_id),
|
|
2319
|
+
helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), buildCursorParams(100, void 0))
|
|
2320
|
+
]);
|
|
2321
|
+
const allCategories = categoryScope.categories;
|
|
2322
|
+
const allSections = sectionsRes.sections ?? [];
|
|
2323
|
+
const categories = allCategories.slice(0, TRANSLATION_GAP_SCAN_MAX_NODES);
|
|
2324
|
+
const sections = allSections.slice(0, Math.max(0, TRANSLATION_GAP_SCAN_MAX_NODES - categories.length));
|
|
2325
|
+
const categoryGaps = await probeInWaves(categories, async (category) => classifyGap(category, await listNodeTranslations(subdomain, token, "categories", category.id, locale), locale));
|
|
2326
|
+
const sectionGaps = await probeInWaves(sections, async (section) => classifyGap(section, await listNodeTranslations(subdomain, token, "sections", section.id, locale), locale));
|
|
2327
|
+
return { content: [{
|
|
2328
|
+
type: "text",
|
|
2329
|
+
text: renderGapReport({
|
|
2330
|
+
locale,
|
|
2331
|
+
activeLocales: locales.locales ?? [],
|
|
2332
|
+
categoryGaps,
|
|
2333
|
+
sectionGaps,
|
|
2334
|
+
scanned: {
|
|
2335
|
+
categories: categories.length,
|
|
2336
|
+
sections: sections.length
|
|
2337
|
+
},
|
|
2338
|
+
found: {
|
|
2339
|
+
categories: allCategories.length,
|
|
2340
|
+
sections: allSections.length
|
|
2341
|
+
},
|
|
2342
|
+
listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more
|
|
2343
|
+
})
|
|
2344
|
+
}] };
|
|
2345
|
+
}
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
name: "set_section_translation",
|
|
2349
|
+
namespace: "help_center",
|
|
2350
|
+
readOnly: false,
|
|
2351
|
+
title: "Create or Update a Section Translation",
|
|
2352
|
+
description: "Create or update the translation of a Help Center section in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a section reachable in a locale where its articles are already translated — a gap find_translation_gaps reports and list_sections cannot explain.",
|
|
2353
|
+
inputSchema: z.object({
|
|
2354
|
+
section_id: z.number().int().describe("Section ID — the numeric id of the section whose translation to write. Obtain it from list_sections, find_translation_gaps or the zendesk-hc://topology resource."),
|
|
2355
|
+
locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_section_translations shows which ones the section already has."),
|
|
2356
|
+
name: z.string().min(1).optional().describe("Localized section name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
|
|
2357
|
+
description: z.string().optional().describe("Localized section description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
|
|
2358
|
+
draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the section visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
|
|
2359
|
+
}),
|
|
2360
|
+
annotations: {
|
|
2361
|
+
readOnlyHint: false,
|
|
2362
|
+
destructiveHint: true,
|
|
2363
|
+
idempotentHint: true,
|
|
2364
|
+
openWorldHint: true
|
|
2365
|
+
},
|
|
2366
|
+
handler: async (params) => {
|
|
2367
|
+
const { section_id, ...input } = params;
|
|
2368
|
+
const token = await getToken();
|
|
2369
|
+
const { translation, created } = await upsertNodeTranslation(subdomain, token, "sections", section_id, input);
|
|
2370
|
+
return { content: [{
|
|
2371
|
+
type: "text",
|
|
2372
|
+
text: nodeTranslationWriteText("sections", section_id, translation, created)
|
|
2373
|
+
}] };
|
|
2374
|
+
}
|
|
2375
|
+
},
|
|
2376
|
+
{
|
|
2377
|
+
name: "set_category_translation",
|
|
2378
|
+
namespace: "help_center",
|
|
2379
|
+
readOnly: false,
|
|
2380
|
+
title: "Create or Update a Category Translation",
|
|
2381
|
+
description: "Create or update the translation of a Help Center category in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a category reachable in a locale where its sections or articles are already translated — a gap find_translation_gaps reports and list_categories cannot explain.",
|
|
2382
|
+
inputSchema: z.object({
|
|
2383
|
+
category_id: z.number().int().describe("Category ID — the numeric id of the category whose translation to write. Obtain it from list_categories, find_translation_gaps or the zendesk-hc://topology resource."),
|
|
2384
|
+
locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_category_translations shows which ones the category already has."),
|
|
2385
|
+
name: z.string().min(1).optional().describe("Localized category name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
|
|
2386
|
+
description: z.string().optional().describe("Localized category description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
|
|
2387
|
+
draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the category visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
|
|
2388
|
+
}),
|
|
2389
|
+
annotations: {
|
|
2390
|
+
readOnlyHint: false,
|
|
2391
|
+
destructiveHint: true,
|
|
2392
|
+
idempotentHint: true,
|
|
2393
|
+
openWorldHint: true
|
|
2394
|
+
},
|
|
2395
|
+
handler: async (params) => {
|
|
2396
|
+
const { category_id, ...input } = params;
|
|
2397
|
+
const token = await getToken();
|
|
2398
|
+
const { translation, created } = await upsertNodeTranslation(subdomain, token, "categories", category_id, input);
|
|
2399
|
+
return { content: [{
|
|
2400
|
+
type: "text",
|
|
2401
|
+
text: nodeTranslationWriteText("categories", category_id, translation, created)
|
|
2402
|
+
}] };
|
|
2403
|
+
}
|
|
2404
|
+
},
|
|
1988
2405
|
{
|
|
1989
2406
|
name: "list_permission_groups",
|
|
1990
2407
|
namespace: "help_center",
|
|
@@ -2108,25 +2525,17 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2108
2525
|
},
|
|
2109
2526
|
handler: async (params) => {
|
|
2110
2527
|
const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
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").`);
|
|
2114
|
-
if (reference_article_id !== void 0 && reference_article_id === article_id) throw new Error("reference_article_id must differ from article_id.");
|
|
2528
|
+
assertReorderParamsCoherent(article_id, target, reference_article_id);
|
|
2529
|
+
const needsReference = needsReferenceArticle(target);
|
|
2115
2530
|
const token = await getToken();
|
|
2116
2531
|
const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
|
|
2117
2532
|
const sectionId = article.section_id;
|
|
2118
2533
|
const locale = article.source_locale;
|
|
2119
2534
|
const effective = await fetchSectionOrder(sectionId, locale, token);
|
|
2120
|
-
if (
|
|
2121
|
-
let detail = "was not found";
|
|
2122
|
-
try {
|
|
2123
|
-
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
|
|
2124
|
-
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
2125
|
-
} catch {}
|
|
2126
|
-
throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
|
|
2127
|
-
}
|
|
2535
|
+
if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
|
|
2128
2536
|
const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
|
|
2129
|
-
const
|
|
2537
|
+
const desired = arrangeDesiredOrder(effective, article_id, target, reference_article_id);
|
|
2538
|
+
const writes = computePositionWrites(desired, article_id, normalize);
|
|
2130
2539
|
if (writes.length === 0) return { content: [{
|
|
2131
2540
|
type: "text",
|
|
2132
2541
|
text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
|
|
@@ -2139,15 +2548,9 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2139
2548
|
type: "text",
|
|
2140
2549
|
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.`
|
|
2141
2550
|
}] };
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
applied += 1;
|
|
2146
|
-
} catch (error) {
|
|
2147
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
2148
|
-
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 });
|
|
2149
|
-
}
|
|
2150
|
-
if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
|
|
2551
|
+
const applied = await applyPositionWrites(writes, article_id, token);
|
|
2552
|
+
const after = await fetchSectionOrder(sectionId, locale, token);
|
|
2553
|
+
if (!isPlacedAsRequested(after, article_id, target, reference_article_id)) return { content: [{
|
|
2151
2554
|
type: "text",
|
|
2152
2555
|
text: autoSortNotice(sectionId, applied)
|
|
2153
2556
|
}] };
|
|
@@ -2258,9 +2661,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2258
2661
|
},
|
|
2259
2662
|
handler: async () => {
|
|
2260
2663
|
const token = await getToken();
|
|
2664
|
+
const response = await helpCenterGet(subdomain, token, "/articles/labels");
|
|
2261
2665
|
return { content: [{
|
|
2262
2666
|
type: "text",
|
|
2263
|
-
text: formatList(
|
|
2667
|
+
text: formatList(response.labels ?? [], formatLabel)
|
|
2264
2668
|
}] };
|
|
2265
2669
|
}
|
|
2266
2670
|
},
|
|
@@ -2386,14 +2790,15 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2386
2790
|
const section = sections[section_index];
|
|
2387
2791
|
if (!section) throw new Error(`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`);
|
|
2388
2792
|
const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
|
|
2793
|
+
const text = [
|
|
2794
|
+
section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
|
|
2795
|
+
`_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
|
|
2796
|
+
"",
|
|
2797
|
+
content
|
|
2798
|
+
].join("\n");
|
|
2389
2799
|
return { content: [{
|
|
2390
2800
|
type: "text",
|
|
2391
|
-
text: truncateIfNeeded(
|
|
2392
|
-
section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
|
|
2393
|
-
`_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
|
|
2394
|
-
"",
|
|
2395
|
-
content
|
|
2396
|
-
].join("\n"))
|
|
2801
|
+
text: truncateIfNeeded(text)
|
|
2397
2802
|
}] };
|
|
2398
2803
|
}
|
|
2399
2804
|
},
|
|
@@ -2458,40 +2863,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2458
2863
|
]);
|
|
2459
2864
|
const sourceSections = parseSections(sourceRes.translation.body);
|
|
2460
2865
|
const targetSections = parseSections(targetRes.translation.body);
|
|
2461
|
-
const
|
|
2462
|
-
const
|
|
2463
|
-
const
|
|
2464
|
-
const
|
|
2465
|
-
const tgtMs = Date.parse(targetUpdated);
|
|
2466
|
-
const comparable = Number.isFinite(srcMs) && Number.isFinite(tgtMs);
|
|
2467
|
-
let freshnessLine;
|
|
2468
|
-
if (comparable && srcMs > tgtMs) {
|
|
2469
|
-
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
2470
|
-
freshnessLine = `- **Freshness (target ${target_locale})**: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
2471
|
-
} else if (comparable) freshnessLine = `- **Freshness (target ${target_locale})**: up to date (source has not been edited since this translation).`;
|
|
2472
|
-
else freshnessLine = `- **Freshness (target ${target_locale})**: unknown (could not compare edit timestamps).`;
|
|
2473
|
-
const targetLocaleKey = target_locale.toLowerCase();
|
|
2474
|
-
const targetListEntry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
2475
|
-
const outdated = targetListEntry?.outdated === void 0 ? "unknown" : targetListEntry.outdated ? "yes" : "no";
|
|
2476
|
-
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.`;
|
|
2477
|
-
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
2478
|
-
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
2479
|
-
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.`;
|
|
2480
|
-
const rows = [];
|
|
2481
|
-
rows.push(`| Idx | Heading | Status | Source words | Target words |`);
|
|
2482
|
-
rows.push(`| --- | --- | --- | --- | --- |`);
|
|
2483
|
-
for (let i = 0; i < maxLen; i += 1) {
|
|
2484
|
-
const src = sourceSections[i];
|
|
2485
|
-
const tgt = targetSections[i];
|
|
2486
|
-
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
2487
|
-
const sourceWords = src?.wordCount ?? 0;
|
|
2488
|
-
const targetWords = tgt?.wordCount ?? 0;
|
|
2489
|
-
let status;
|
|
2490
|
-
if (!tgt) status = "missing";
|
|
2491
|
-
else if (!src) status = "extra";
|
|
2492
|
-
else status = "ok";
|
|
2493
|
-
rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
|
|
2494
|
-
}
|
|
2866
|
+
const freshnessLine = renderFreshnessLine(sourceRes.translation.updated_at, targetRes.translation.updated_at, target_locale);
|
|
2867
|
+
const outdatedLine = renderOutdatedLine(translations, target_locale);
|
|
2868
|
+
const structureLine = renderStructureLine(sourceSections, targetSections);
|
|
2869
|
+
const rows = renderSectionRows(sourceSections, targetSections);
|
|
2495
2870
|
return { content: [{
|
|
2496
2871
|
type: "text",
|
|
2497
2872
|
text: [
|
|
@@ -2500,7 +2875,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2500
2875
|
freshnessLine,
|
|
2501
2876
|
outdatedLine,
|
|
2502
2877
|
structureLine,
|
|
2503
|
-
`- **Updated**: source ${
|
|
2878
|
+
`- **Updated**: source ${sourceRes.translation.updated_at} | target ${targetRes.translation.updated_at}`,
|
|
2504
2879
|
`- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
|
|
2505
2880
|
"",
|
|
2506
2881
|
"_Word counts are informational: a length difference between languages is normal, not a divergence._",
|
|
@@ -2587,15 +2962,18 @@ const createSearchTools = (ctx) => {
|
|
|
2587
2962
|
});
|
|
2588
2963
|
const results = response.results ?? [];
|
|
2589
2964
|
const meta = extractSearchPaginationMeta(response, per_page, page);
|
|
2965
|
+
const header = `Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`;
|
|
2966
|
+
const body = results.map(formatSearchResult).join("\n\n");
|
|
2590
2967
|
return { content: [{
|
|
2591
2968
|
type: "text",
|
|
2592
|
-
text: truncateIfNeeded([
|
|
2969
|
+
text: truncateIfNeeded([header, body].filter(Boolean).join("\n\n"))
|
|
2593
2970
|
}] };
|
|
2594
2971
|
}
|
|
2595
2972
|
}];
|
|
2596
2973
|
};
|
|
2597
2974
|
//#endregion
|
|
2598
2975
|
//#region src/tools/tickets.ts
|
|
2976
|
+
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
|
|
2599
2977
|
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
2600
2978
|
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
2601
2979
|
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
@@ -2624,6 +3002,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
|
2624
3002
|
}
|
|
2625
3003
|
return all;
|
|
2626
3004
|
};
|
|
3005
|
+
const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
3006
|
+
const attachments = [];
|
|
3007
|
+
for (const id of ids) try {
|
|
3008
|
+
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
3009
|
+
attachments.push(attachment);
|
|
3010
|
+
} catch (error) {
|
|
3011
|
+
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
3012
|
+
}
|
|
3013
|
+
return attachments;
|
|
3014
|
+
};
|
|
2627
3015
|
const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
2628
3016
|
const blocks = [];
|
|
2629
3017
|
let embeddedCount = 0;
|
|
@@ -2637,7 +3025,7 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
2637
3025
|
continue;
|
|
2638
3026
|
}
|
|
2639
3027
|
let skipReason = null;
|
|
2640
|
-
if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${
|
|
3028
|
+
if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
|
|
2641
3029
|
else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
|
|
2642
3030
|
if (skipReason) {
|
|
2643
3031
|
blocks.push({
|
|
@@ -2659,9 +3047,10 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
2659
3047
|
}
|
|
2660
3048
|
return blocks;
|
|
2661
3049
|
};
|
|
3050
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
2662
3051
|
const fetchTicketSla = async (subdomain, token, ticket) => {
|
|
2663
3052
|
const day = ticket.created_at.slice(0, 10);
|
|
2664
|
-
if (
|
|
3053
|
+
if (!ISO_DAY.test(day)) return void 0;
|
|
2665
3054
|
const shiftDay = (offset) => {
|
|
2666
3055
|
const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
|
|
2667
3056
|
d.setUTCDate(d.getUTCDate() + offset);
|
|
@@ -2731,23 +3120,24 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
|
|
|
2731
3120
|
const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
|
|
2732
3121
|
return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
2733
3122
|
};
|
|
3123
|
+
const addPositiveId = (set, raw) => {
|
|
3124
|
+
const n = Number(raw);
|
|
3125
|
+
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
3126
|
+
};
|
|
3127
|
+
const collectEventIds = (event, userIds, groupIds) => {
|
|
3128
|
+
if (event.type !== "Change" && event.type !== "Create") return;
|
|
3129
|
+
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
3130
|
+
if (!entity) return;
|
|
3131
|
+
const set = entity === "user" ? userIds : groupIds;
|
|
3132
|
+
addPositiveId(set, event.value);
|
|
3133
|
+
addPositiveId(set, event.previous_value);
|
|
3134
|
+
};
|
|
2734
3135
|
const collectAuditIds = (audits) => {
|
|
2735
3136
|
const userIds = /* @__PURE__ */ new Set();
|
|
2736
3137
|
const groupIds = /* @__PURE__ */ new Set();
|
|
2737
|
-
const addId = (set, raw) => {
|
|
2738
|
-
const n = Number(raw);
|
|
2739
|
-
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
2740
|
-
};
|
|
2741
3138
|
for (const audit of audits) {
|
|
2742
|
-
|
|
2743
|
-
for (const event of audit.events)
|
|
2744
|
-
if (event.type !== "Change" && event.type !== "Create") continue;
|
|
2745
|
-
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
2746
|
-
if (!entity) continue;
|
|
2747
|
-
const set = entity === "user" ? userIds : groupIds;
|
|
2748
|
-
addId(set, event.value);
|
|
2749
|
-
addId(set, event.previous_value);
|
|
2750
|
-
}
|
|
3139
|
+
addPositiveId(userIds, audit.author_id);
|
|
3140
|
+
for (const event of audit.events) collectEventIds(event, userIds, groupIds);
|
|
2751
3141
|
}
|
|
2752
3142
|
return {
|
|
2753
3143
|
userIds: [...userIds],
|
|
@@ -2790,12 +3180,11 @@ const diffLine = (label, before, after) => {
|
|
|
2790
3180
|
const a = shownValue(after);
|
|
2791
3181
|
return b === a ? null : `- **${label}**: ${b} → ${a}`;
|
|
2792
3182
|
};
|
|
2793
|
-
const
|
|
2794
|
-
|
|
2795
|
-
const beforeObj = before
|
|
2796
|
-
const comment = after.comment ?? result?.comment;
|
|
3183
|
+
const asRecord = (value) => value ?? {};
|
|
3184
|
+
const diffStandardFields = (before, after) => {
|
|
3185
|
+
const beforeObj = asRecord(before);
|
|
2797
3186
|
const changes = [];
|
|
2798
|
-
for (const [key, afterVal] of Object.entries(after)) {
|
|
3187
|
+
for (const [key, afterVal] of Object.entries(asRecord(after))) {
|
|
2799
3188
|
if (DIFF_SKIP_KEYS.has(key)) continue;
|
|
2800
3189
|
const beforeVal = beforeObj[key];
|
|
2801
3190
|
if (valuesEqual(beforeVal, afterVal)) continue;
|
|
@@ -2808,12 +3197,22 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
|
2808
3197
|
const line = diffLine(key, beforeVal, afterVal);
|
|
2809
3198
|
if (line) changes.push(line);
|
|
2810
3199
|
}
|
|
2811
|
-
|
|
3200
|
+
return changes;
|
|
3201
|
+
};
|
|
3202
|
+
const diffCustomFields = (before, after) => {
|
|
3203
|
+
const afterFields = [after?.fields ?? after?.custom_fields ?? []].flat();
|
|
2812
3204
|
const beforeById = new Map((before?.custom_fields ?? []).map((f) => [f.id, f.value]));
|
|
3205
|
+
const changes = [];
|
|
2813
3206
|
for (const f of afterFields) {
|
|
2814
3207
|
const line = diffLine(`custom field ${f.id}`, beforeById.get(f.id), f.value);
|
|
2815
3208
|
if (line) changes.push(line);
|
|
2816
3209
|
}
|
|
3210
|
+
return changes;
|
|
3211
|
+
};
|
|
3212
|
+
const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
3213
|
+
const after = result?.ticket ?? {};
|
|
3214
|
+
const comment = after.comment ?? result?.comment;
|
|
3215
|
+
const changes = [...diffStandardFields(before, after), ...diffCustomFields(before, after)];
|
|
2817
3216
|
const lines = [
|
|
2818
3217
|
`# Macro #${macroId} preview on ticket #${ticketId} (diff — nothing saved yet)`,
|
|
2819
3218
|
"",
|
|
@@ -2939,16 +3338,7 @@ const createTicketTools = (ctx) => {
|
|
|
2939
3338
|
handler: async (params) => {
|
|
2940
3339
|
const { ticket_id, attachment_ids } = params;
|
|
2941
3340
|
const token = await getToken();
|
|
2942
|
-
|
|
2943
|
-
if (attachment_ids && attachment_ids.length > 0) {
|
|
2944
|
-
attachments = [];
|
|
2945
|
-
for (const id of attachment_ids) try {
|
|
2946
|
-
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
2947
|
-
attachments.push(attachment);
|
|
2948
|
-
} catch (error) {
|
|
2949
|
-
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
2950
|
-
}
|
|
2951
|
-
} else attachments = (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
3341
|
+
const attachments = attachment_ids && attachment_ids.length > 0 ? await fetchAttachmentsByIds(subdomain, token, attachment_ids) : (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
2952
3342
|
if (attachments.length === 0) return { content: [{
|
|
2953
3343
|
type: "text",
|
|
2954
3344
|
text: `No attachments found on ticket #${ticket_id}.`
|
|
@@ -3202,9 +3592,10 @@ const createTicketTools = (ctx) => {
|
|
|
3202
3592
|
const { problem_id } = params;
|
|
3203
3593
|
const token = await getToken();
|
|
3204
3594
|
const incidents = (await zendeskGet(subdomain, token, `/tickets/${problem_id}/incidents`)).tickets ?? [];
|
|
3595
|
+
const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
|
|
3205
3596
|
return { content: [{
|
|
3206
3597
|
type: "text",
|
|
3207
|
-
text: truncateIfNeeded(
|
|
3598
|
+
text: truncateIfNeeded(text)
|
|
3208
3599
|
}] };
|
|
3209
3600
|
}
|
|
3210
3601
|
},
|
|
@@ -3270,9 +3661,10 @@ const createTicketTools = (ctx) => {
|
|
|
3270
3661
|
throw error;
|
|
3271
3662
|
}
|
|
3272
3663
|
const policies = response.sla_policies ?? [];
|
|
3664
|
+
const meta = extractOffsetPaginationMeta(response, policies.length, per_page, page);
|
|
3273
3665
|
return { content: [{
|
|
3274
3666
|
type: "text",
|
|
3275
|
-
text: formatList(policies, formatSlaPolicy,
|
|
3667
|
+
text: formatList(policies, formatSlaPolicy, meta)
|
|
3276
3668
|
}] };
|
|
3277
3669
|
}
|
|
3278
3670
|
},
|
|
@@ -3406,9 +3798,10 @@ const createTicketTools = (ctx) => {
|
|
|
3406
3798
|
const token = await getToken();
|
|
3407
3799
|
const response = await zendeskGet(subdomain, token, "/macros/active", buildOffsetParams(per_page, page));
|
|
3408
3800
|
const macros = response.macros ?? [];
|
|
3801
|
+
const meta = extractOffsetPaginationMeta(response, macros.length, per_page, page);
|
|
3409
3802
|
return { content: [{
|
|
3410
3803
|
type: "text",
|
|
3411
|
-
text: formatList(macros, formatMacro,
|
|
3804
|
+
text: formatList(macros, formatMacro, meta)
|
|
3412
3805
|
}] };
|
|
3413
3806
|
}
|
|
3414
3807
|
},
|
|
@@ -3753,6 +4146,10 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3753
4146
|
case "single":
|
|
3754
4147
|
registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
|
|
3755
4148
|
break;
|
|
4149
|
+
default: {
|
|
4150
|
+
const unhandled = config.mode;
|
|
4151
|
+
throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
|
|
4152
|
+
}
|
|
3756
4153
|
}
|
|
3757
4154
|
if (helpCenterContextEnabled(config)) {
|
|
3758
4155
|
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
@@ -3840,7 +4237,8 @@ const startStdioTransport = async (server, logger = silentLogger) => {
|
|
|
3840
4237
|
};
|
|
3841
4238
|
//#endregion
|
|
3842
4239
|
//#region src/dev/reload.ts
|
|
3843
|
-
const
|
|
4240
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
4241
|
+
const toolsDir = join(thisDir, "..", "tools");
|
|
3844
4242
|
const TOOL_MODULES = [
|
|
3845
4243
|
{
|
|
3846
4244
|
file: "tickets.ts",
|
|
@@ -3979,6 +4377,7 @@ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
|
3979
4377
|
"::",
|
|
3980
4378
|
"*"
|
|
3981
4379
|
]);
|
|
4380
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
3982
4381
|
const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
|
|
3983
4382
|
"https://chatgpt.com",
|
|
3984
4383
|
"https://chat.openai.com",
|
|
@@ -4054,7 +4453,7 @@ const handleCorsPreflight = (req, res, extraOrigins) => {
|
|
|
4054
4453
|
return true;
|
|
4055
4454
|
};
|
|
4056
4455
|
const resolveResourceUrl = (config, logger = silentLogger) => {
|
|
4057
|
-
if (config.publicUrl) return config.publicUrl.replace(
|
|
4456
|
+
if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
|
|
4058
4457
|
if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
|
|
4059
4458
|
logger.warn("public_url_unset", {
|
|
4060
4459
|
host: config.host,
|
|
@@ -4111,6 +4510,14 @@ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
|
|
|
4111
4510
|
jsonrpc: "2.0"
|
|
4112
4511
|
}));
|
|
4113
4512
|
};
|
|
4513
|
+
const failRequest = (res, err) => {
|
|
4514
|
+
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
4515
|
+
if (!res.headersSent) {
|
|
4516
|
+
sendJsonRpcError(res, 500, -32603, message);
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4519
|
+
if (!res.writableEnded) res.end();
|
|
4520
|
+
};
|
|
4114
4521
|
const sendUnauthorized = (res, resource) => {
|
|
4115
4522
|
const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
|
|
4116
4523
|
sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
|
|
@@ -4174,13 +4581,29 @@ const respondBodyError = (req, res, failure) => {
|
|
|
4174
4581
|
if (failure.status === 413) if (res.writableFinished) req.destroy();
|
|
4175
4582
|
else res.once("finish", () => req.destroy());
|
|
4176
4583
|
};
|
|
4177
|
-
const SESSION_IDLE_TIMEOUT_MS =
|
|
4178
|
-
const SESSION_SWEEP_INTERVAL_MS =
|
|
4584
|
+
const SESSION_IDLE_TIMEOUT_MS = 18e5;
|
|
4585
|
+
const SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
4179
4586
|
const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
|
|
4180
4587
|
const metadata = buildOAuthMetadata(config, logger);
|
|
4181
4588
|
const sessions = /* @__PURE__ */ new Map();
|
|
4182
4589
|
const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
|
|
4183
4590
|
const maxBodyBytes = options.maxBodyBytes ?? 4194304;
|
|
4591
|
+
const dispatchToSession = async (req, res, sessionId, bearer) => {
|
|
4592
|
+
const session = sessions.get(sessionId);
|
|
4593
|
+
if (!session) return false;
|
|
4594
|
+
session.auth.bearer = bearer;
|
|
4595
|
+
session.lastActivityAt = Date.now();
|
|
4596
|
+
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
4597
|
+
ok: true,
|
|
4598
|
+
value: void 0
|
|
4599
|
+
};
|
|
4600
|
+
if (!body.ok) {
|
|
4601
|
+
respondBodyError(req, res, body);
|
|
4602
|
+
return true;
|
|
4603
|
+
}
|
|
4604
|
+
await session.transport.handleRequest(req, res, body.value);
|
|
4605
|
+
return true;
|
|
4606
|
+
};
|
|
4184
4607
|
const handleMcpRequest = async (req, res) => {
|
|
4185
4608
|
const bearer = extractBearer(req);
|
|
4186
4609
|
if (!bearer) {
|
|
@@ -4188,23 +4611,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4188
4611
|
return;
|
|
4189
4612
|
}
|
|
4190
4613
|
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
|
|
4191
|
-
if (sessionId)
|
|
4192
|
-
const session = sessions.get(sessionId);
|
|
4193
|
-
if (session) {
|
|
4194
|
-
session.auth.bearer = bearer;
|
|
4195
|
-
session.lastActivityAt = Date.now();
|
|
4196
|
-
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
4197
|
-
ok: true,
|
|
4198
|
-
value: void 0
|
|
4199
|
-
};
|
|
4200
|
-
if (!body.ok) {
|
|
4201
|
-
respondBodyError(req, res, body);
|
|
4202
|
-
return;
|
|
4203
|
-
}
|
|
4204
|
-
await session.transport.handleRequest(req, res, body.value);
|
|
4205
|
-
return;
|
|
4206
|
-
}
|
|
4207
|
-
}
|
|
4614
|
+
if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
|
|
4208
4615
|
if (req.method !== "POST") {
|
|
4209
4616
|
sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
|
|
4210
4617
|
return;
|
|
@@ -4236,24 +4643,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4236
4643
|
await server.connect(transport);
|
|
4237
4644
|
await transport.handleRequest(req, res, body.value);
|
|
4238
4645
|
};
|
|
4646
|
+
const staticGetRoutes = {
|
|
4647
|
+
"/.well-known/oauth-protected-resource": metadata.protectedResource,
|
|
4648
|
+
"/.well-known/oauth-authorization-server": metadata.authorizationServer,
|
|
4649
|
+
"/healthz": {
|
|
4650
|
+
status: "ok",
|
|
4651
|
+
subdomain: config.subdomain
|
|
4652
|
+
}
|
|
4653
|
+
};
|
|
4239
4654
|
const requestListener = async (req, res) => {
|
|
4240
4655
|
try {
|
|
4241
4656
|
if (handleCorsPreflight(req, res, config.corsOrigins)) return;
|
|
4242
4657
|
applyCorsHeaders(req, res, config.corsOrigins);
|
|
4243
4658
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
}
|
|
4248
|
-
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
4249
|
-
sendJson(res, 200, metadata.authorizationServer);
|
|
4250
|
-
return;
|
|
4251
|
-
}
|
|
4252
|
-
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
4253
|
-
sendJson(res, 200, {
|
|
4254
|
-
status: "ok",
|
|
4255
|
-
subdomain: config.subdomain
|
|
4256
|
-
});
|
|
4659
|
+
const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
|
|
4660
|
+
if (staticRoute) {
|
|
4661
|
+
sendJson(res, 200, staticRoute);
|
|
4257
4662
|
return;
|
|
4258
4663
|
}
|
|
4259
4664
|
if (url.pathname === "/mcp") {
|
|
@@ -4266,9 +4671,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4266
4671
|
path: url.pathname
|
|
4267
4672
|
}));
|
|
4268
4673
|
} catch (err) {
|
|
4269
|
-
|
|
4270
|
-
if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
|
|
4271
|
-
else if (!res.writableEnded) res.end();
|
|
4674
|
+
failRequest(res, err);
|
|
4272
4675
|
}
|
|
4273
4676
|
};
|
|
4274
4677
|
const httpServer = createServer((req, res) => {
|
|
@@ -4333,7 +4736,8 @@ const main = async () => {
|
|
|
4333
4736
|
await startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
4334
4737
|
return;
|
|
4335
4738
|
}
|
|
4336
|
-
|
|
4739
|
+
const server = createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
4740
|
+
await startStdioTransport(server, logger);
|
|
4337
4741
|
return;
|
|
4338
4742
|
}
|
|
4339
4743
|
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
@@ -4345,5 +4749,3 @@ main().catch((error) => {
|
|
|
4345
4749
|
});
|
|
4346
4750
|
//#endregion
|
|
4347
4751
|
export {};
|
|
4348
|
-
|
|
4349
|
-
//# sourceMappingURL=index.js.map
|