@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.
Files changed (2) hide show
  1. package/dist/server.js +189 -26
  2. 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 = validateText("tool description", tool.description, 2e4);
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 ensureChildToolCatalog(client, notify) {
528
- if (childCatalogLoaded) return;
529
- if (catalogLoadPromise !== null) return catalogLoadPromise;
530
- catalogLoadPromise = (async () => {
531
- const { tools } = await client.listTools(void 0, {
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
- return catalogLoadPromise;
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 > 100) throw new Error(`Connection exceeds ${String(100)} accessible sites`);
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) await ensureChildToolCatalog(firstSite.client, false);
771
- else {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/atlassian-mcp",
3
- "version": "0.3.27",
3
+ "version": "0.3.28",
4
4
  "description": "Atlassian MCP proxy server — bridges sooperset/mcp-atlassian with Alfe OAuth credentials (Pattern A multi-site)",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",