@alfe.ai/atlassian-mcp 0.3.27 → 0.3.28
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/dist/server.js +189 -26
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -9,7 +9,6 @@ import { resolveConfig } from "@alfe.ai/config";
|
|
|
9
9
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
10
10
|
import { assertPatternA } from "@alfe.ai/mcp-bundler";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
-
//#region src/boundary.ts
|
|
13
12
|
const MAX_FORWARDED_ARGUMENT_BYTES = 1024 * 1024;
|
|
14
13
|
const MAX_TOOL_RESULT_BYTES = 5 * 1024 * 1024;
|
|
15
14
|
const MAX_SCHEMA_BYTES = 512 * 1024;
|
|
@@ -130,7 +129,7 @@ function injectCloudIdSelector(tool) {
|
|
|
130
129
|
}
|
|
131
130
|
};
|
|
132
131
|
if (tool.title !== void 0) result.title = validateText("tool title", tool.title, 256);
|
|
133
|
-
if (tool.description !== void 0) result.description =
|
|
132
|
+
if (tool.description !== void 0) result.description = validateDocumentationText("tool description", tool.description, 2e4);
|
|
134
133
|
if (tool.outputSchema !== void 0) result.outputSchema = cloneJsonRecord("tool output schema", tool.outputSchema, MAX_SCHEMA_BYTES, 1e5);
|
|
135
134
|
if (tool.annotations !== void 0) result.annotations = validateAnnotations(tool.annotations);
|
|
136
135
|
if (tool.execution !== void 0) {
|
|
@@ -261,6 +260,130 @@ function validateText(label, value, maxChars) {
|
|
|
261
260
|
if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
|
|
262
261
|
return value;
|
|
263
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Like `validateText`, but for human-readable documentation (tool
|
|
265
|
+
* descriptions) that legitimately spans lines. Tab, LF and CR are
|
|
266
|
+
* allowed; every other C0 control character and DEL is still rejected.
|
|
267
|
+
* Identifiers and titles keep the strict `validateText` rule.
|
|
268
|
+
*/
|
|
269
|
+
function validateDocumentationText(label, value, maxChars) {
|
|
270
|
+
if (value.length < 1 || value.length > maxChars || containsUnsafeDocumentationCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} safe characters`);
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Keep the first occurrence of every cloudId. Atlassian's
|
|
275
|
+
* accessible-resources returns one entry per product (Jira and
|
|
276
|
+
* Confluence) for the same site, so a single Connection can list the same
|
|
277
|
+
* cloudId twice. Without this, the second entry fails the Connection's own
|
|
278
|
+
* first-owner claim and surfaces as a spurious `duplicate_cloudId` row.
|
|
279
|
+
*/
|
|
280
|
+
function dedupeSitesById(sites) {
|
|
281
|
+
const seen = /* @__PURE__ */ new Set();
|
|
282
|
+
const result = [];
|
|
283
|
+
for (const site of sites) {
|
|
284
|
+
if (seen.has(site.id)) continue;
|
|
285
|
+
seen.add(site.id);
|
|
286
|
+
result.push(site);
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Deduplicate by cloudId, then enforce MAX_SITES_PER_ACCOUNT on the
|
|
292
|
+
* distinct sites. The per-account limit counts Cloud sites, not product
|
|
293
|
+
* entries: 51 sites with Jira and Confluence each (102 raw entries) are
|
|
294
|
+
* within the limit.
|
|
295
|
+
*/
|
|
296
|
+
function dedupeAndLimitSites(sites) {
|
|
297
|
+
const distinct = dedupeSitesById(sites);
|
|
298
|
+
if (distinct.length > 100) throw new Error(`Connection exceeds ${String(100)} accessible sites`);
|
|
299
|
+
return distinct;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Track child catalog loading separately from the list-changed
|
|
303
|
+
* notification, so a failed notification stays retryable instead of being
|
|
304
|
+
* hidden behind an already-loaded catalog.
|
|
305
|
+
*/
|
|
306
|
+
function createCatalogLoader(sendListChanged) {
|
|
307
|
+
let loaded = false;
|
|
308
|
+
let notificationPending = false;
|
|
309
|
+
let inFlight = null;
|
|
310
|
+
return {
|
|
311
|
+
get loaded() {
|
|
312
|
+
return loaded;
|
|
313
|
+
},
|
|
314
|
+
get settled() {
|
|
315
|
+
return loaded && !notificationPending;
|
|
316
|
+
},
|
|
317
|
+
async ensure(load, notify) {
|
|
318
|
+
if (!loaded) {
|
|
319
|
+
inFlight ??= load().then(() => {
|
|
320
|
+
loaded = true;
|
|
321
|
+
}).finally(() => {
|
|
322
|
+
inFlight = null;
|
|
323
|
+
});
|
|
324
|
+
await inFlight;
|
|
325
|
+
if (notify) notificationPending = true;
|
|
326
|
+
}
|
|
327
|
+
if (notify && notificationPending) {
|
|
328
|
+
await sendListChanged();
|
|
329
|
+
notificationPending = false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Run child tool-catalog discovery without letting a failure escape.
|
|
336
|
+
* A catalog that fails validation (or a child that cannot list tools)
|
|
337
|
+
* must degrade the proxy to its meta tools, never kill it before the
|
|
338
|
+
* stdio handshake. Returns whether discovery succeeded.
|
|
339
|
+
*/
|
|
340
|
+
async function discoverCatalogSafely(load, onFailure, secrets = []) {
|
|
341
|
+
try {
|
|
342
|
+
await load();
|
|
343
|
+
return true;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
onFailure(safeErrorMessage(error, secrets));
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Re-run `attempt` on a doubling, capped delay until it resolves `true`
|
|
351
|
+
* or `stop()` is called. A rejected attempt counts as "not yet". Timers
|
|
352
|
+
* are unref'd so a pending retry never keeps the process alive.
|
|
353
|
+
*/
|
|
354
|
+
function startBackoffRetry(attempt, options) {
|
|
355
|
+
let stopped = false;
|
|
356
|
+
let reportedMaxDelay = false;
|
|
357
|
+
let timer = null;
|
|
358
|
+
let delay = options.initialMs;
|
|
359
|
+
const schedule = () => {
|
|
360
|
+
if (stopped) return;
|
|
361
|
+
timer = setTimeout(() => {
|
|
362
|
+
timer = null;
|
|
363
|
+
attempt().catch(() => false).then((done) => {
|
|
364
|
+
if (done) {
|
|
365
|
+
stopped = true;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
delay = Math.min(delay * 2, options.maxMs);
|
|
369
|
+
if (delay === options.maxMs && !reportedMaxDelay) {
|
|
370
|
+
reportedMaxDelay = true;
|
|
371
|
+
try {
|
|
372
|
+
options.onMaxDelay?.();
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
schedule();
|
|
376
|
+
});
|
|
377
|
+
}, delay);
|
|
378
|
+
timer.unref();
|
|
379
|
+
};
|
|
380
|
+
schedule();
|
|
381
|
+
return { stop() {
|
|
382
|
+
stopped = true;
|
|
383
|
+
if (timer !== null) clearTimeout(timer);
|
|
384
|
+
timer = null;
|
|
385
|
+
} };
|
|
386
|
+
}
|
|
264
387
|
function validateRequired(value) {
|
|
265
388
|
if (value === void 0) return [];
|
|
266
389
|
if (!Array.isArray(value) || value.length > 250) throw new Error("tool schema required must be an array");
|
|
@@ -375,6 +498,12 @@ function cloneRecord(label, value, depth, budget, maxStringChars) {
|
|
|
375
498
|
function isRecord(value) {
|
|
376
499
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
377
500
|
}
|
|
501
|
+
function containsUnsafeDocumentationCharacter(value) {
|
|
502
|
+
return Array.from(value).some((character) => {
|
|
503
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
504
|
+
return codePoint < 32 && character !== " " && character !== "\n" && character !== "\r" || codePoint === 127;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
378
507
|
function containsControlCharacter(value) {
|
|
379
508
|
return Array.from(value).some((character) => {
|
|
380
509
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
@@ -434,9 +563,9 @@ const cloudOwners = /* @__PURE__ */ new Map();
|
|
|
434
563
|
const allSitesSnapshot = [];
|
|
435
564
|
const siteSummaryIndexes = /* @__PURE__ */ new Map();
|
|
436
565
|
let cachedTools = [];
|
|
437
|
-
let childCatalogLoaded = false;
|
|
438
|
-
let catalogLoadPromise = null;
|
|
439
566
|
let proxyServer = null;
|
|
567
|
+
/** No proxy server yet means no connected client to notify. */
|
|
568
|
+
const catalog = createCatalogLoader(() => proxyServer?.sendToolListChanged() ?? Promise.resolve());
|
|
440
569
|
const META_TOOL_NAMES = new Set([
|
|
441
570
|
"atlassian_list_accounts",
|
|
442
571
|
"atlassian_check_connection",
|
|
@@ -524,24 +653,24 @@ async function killAllChildren() {
|
|
|
524
653
|
} catch {}
|
|
525
654
|
sites.clear();
|
|
526
655
|
}
|
|
527
|
-
async function
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
timeout: 3e4,
|
|
533
|
-
signal: AbortSignal.timeout(3e4)
|
|
534
|
-
});
|
|
535
|
-
const childTools = validateToolCatalog(tools);
|
|
536
|
-
const metaTools = cachedTools.filter((tool) => META_TOOL_NAMES.has(tool.name));
|
|
537
|
-
cachedTools = [...childTools, ...metaTools];
|
|
538
|
-
childCatalogLoaded = true;
|
|
539
|
-
log(`Child MCP server provides ${String(childTools.length)} validated tools`);
|
|
540
|
-
if (notify && proxyServer !== null) await proxyServer.sendToolListChanged();
|
|
541
|
-
})().finally(() => {
|
|
542
|
-
catalogLoadPromise = null;
|
|
656
|
+
async function loadChildToolCatalog(client) {
|
|
657
|
+
if (client === void 0) throw new Error("No live Atlassian child server to list tools from");
|
|
658
|
+
const { tools } = await client.listTools(void 0, {
|
|
659
|
+
timeout: 3e4,
|
|
660
|
+
signal: AbortSignal.timeout(3e4)
|
|
543
661
|
});
|
|
544
|
-
|
|
662
|
+
const childTools = validateToolCatalog(tools);
|
|
663
|
+
const metaTools = cachedTools.filter((tool) => META_TOOL_NAMES.has(tool.name));
|
|
664
|
+
cachedTools = [...childTools, ...metaTools];
|
|
665
|
+
log(`Child MCP server provides ${String(childTools.length)} validated tools`);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Load the child catalog once. With `notify`, also send
|
|
669
|
+
* tools/list_changed; a failed notification stays pending (see
|
|
670
|
+
* `createCatalogLoader`) so the catalog retry re-sends it.
|
|
671
|
+
*/
|
|
672
|
+
async function ensureChildToolCatalog(client, notify) {
|
|
673
|
+
await catalog.ensure(() => loadChildToolCatalog(client), notify);
|
|
545
674
|
}
|
|
546
675
|
/**
|
|
547
676
|
* Refresh a specific Connection's access token and atomically swap every
|
|
@@ -568,6 +697,7 @@ async function refreshConnection(apiClient, accountIdentifier) {
|
|
|
568
697
|
await ensureChildToolCatalog(newChild, true).catch((error) => {
|
|
569
698
|
log(`Could not refresh the Atlassian tool catalog: ${safeErrorMessage(error, [accessToken])}`);
|
|
570
699
|
});
|
|
700
|
+
if (!catalog.settled) startCatalogRetry();
|
|
571
701
|
sites.set(intended.id, {
|
|
572
702
|
cloudId: intended.id,
|
|
573
703
|
siteName: intended.name,
|
|
@@ -643,6 +773,35 @@ function scheduleRefresh(apiClient, delayMs = REFRESH_INTERVAL_MS) {
|
|
|
643
773
|
}, delayMs);
|
|
644
774
|
refreshTimer.unref();
|
|
645
775
|
}
|
|
776
|
+
const CATALOG_RETRY_INITIAL_MS = 5e3;
|
|
777
|
+
const CATALOG_RETRY_MAX_MS = 6e4;
|
|
778
|
+
let catalogRetry = null;
|
|
779
|
+
function currentAccessTokens() {
|
|
780
|
+
return Array.from(connections.values(), (connection) => connection.accessToken);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Retry child tool-catalog discovery (5s doubling to 60s) against
|
|
784
|
+
* whichever child is live at the time, until it loads (no attempt cap;
|
|
785
|
+
* one warning is logged when the backoff first reaches 60s). If a refresh
|
|
786
|
+
* respawn loads the catalog first, the next tick stops the retry.
|
|
787
|
+
*/
|
|
788
|
+
function startCatalogRetry() {
|
|
789
|
+
catalogRetry?.stop();
|
|
790
|
+
catalogRetry = startBackoffRetry(async () => {
|
|
791
|
+
if (catalog.settled) return true;
|
|
792
|
+
const site = sites.values().next().value;
|
|
793
|
+
if (!catalog.loaded && !site) return false;
|
|
794
|
+
return discoverCatalogSafely(() => ensureChildToolCatalog(site?.client, true), (message) => {
|
|
795
|
+
log(`Atlassian tool catalog retry failed: ${message}`);
|
|
796
|
+
}, currentAccessTokens());
|
|
797
|
+
}, {
|
|
798
|
+
initialMs: CATALOG_RETRY_INITIAL_MS,
|
|
799
|
+
maxMs: CATALOG_RETRY_MAX_MS,
|
|
800
|
+
onMaxDelay: () => {
|
|
801
|
+
log(`WARN: Atlassian tool catalog still not loaded; retry backoff reached ${String(CATALOG_RETRY_MAX_MS / 1e3)}s and will keep retrying at that interval`);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
}
|
|
646
805
|
async function main() {
|
|
647
806
|
const config = resolveConfig();
|
|
648
807
|
const apiClient = new AgentApiClient({
|
|
@@ -667,12 +826,12 @@ async function main() {
|
|
|
667
826
|
name: acct.siteName,
|
|
668
827
|
url: acct.siteUrl
|
|
669
828
|
}] : [];
|
|
670
|
-
if (rawSites.length >
|
|
671
|
-
intendedSitesForAcct = rawSites.map((site) => ({
|
|
829
|
+
if (rawSites.length > 400) throw new Error(`Connection lists more than ${String(400)} site entries`);
|
|
830
|
+
intendedSitesForAcct = dedupeAndLimitSites(rawSites.map((site) => ({
|
|
672
831
|
id: validateCloudId(site.id),
|
|
673
832
|
name: validateSiteName(site.name),
|
|
674
833
|
url: validateSiteUrl(site.url)
|
|
675
|
-
}));
|
|
834
|
+
})));
|
|
676
835
|
totalSites += intendedSitesForAcct.length;
|
|
677
836
|
if (totalSites > 250) throw new Error(`Connect returned more than ${String(250)} Atlassian sites`);
|
|
678
837
|
} catch (error) {
|
|
@@ -767,8 +926,11 @@ async function main() {
|
|
|
767
926
|
}
|
|
768
927
|
}
|
|
769
928
|
const firstSite = sites.values().next().value;
|
|
770
|
-
if (firstSite)
|
|
771
|
-
|
|
929
|
+
if (firstSite) {
|
|
930
|
+
if (!await discoverCatalogSafely(() => ensureChildToolCatalog(firstSite.client, false), (message) => {
|
|
931
|
+
log(`Atlassian tool catalog discovery failed; exposing only the meta tools and retrying: ${message}`);
|
|
932
|
+
}, currentAccessTokens())) startCatalogRetry();
|
|
933
|
+
} else {
|
|
772
934
|
cachedTools = [];
|
|
773
935
|
log("No child server available — only atlassian_list_accounts, atlassian_check_connection, and atlassian_refresh_token will be exposed");
|
|
774
936
|
}
|
|
@@ -1038,6 +1200,7 @@ let shutdownPromise = null;
|
|
|
1038
1200
|
function shutdown(exitCode) {
|
|
1039
1201
|
if (shutdownPromise !== null) return;
|
|
1040
1202
|
if (refreshTimer) clearTimeout(refreshTimer);
|
|
1203
|
+
catalogRetry?.stop();
|
|
1041
1204
|
shutdownPromise = killAllChildren().finally(() => {
|
|
1042
1205
|
process.exit(exitCode);
|
|
1043
1206
|
});
|
package/package.json
CHANGED