@alfe.ai/atlassian-mcp 0.3.17 → 0.3.19

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 (3) hide show
  1. package/README.md +7 -0
  2. package/dist/server.js +670 -215
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -10,6 +10,13 @@ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: buil
10
10
  npm install @alfe.ai/atlassian-mcp
11
11
  ```
12
12
 
13
+ The proxy supports multiple Atlassian OAuth Connections and Cloud sites.
14
+ Every Jira/Confluence tool requires the `cloudId` returned by
15
+ `atlassian_list_accounts`; access tokens stay bound to that selector's
16
+ first authoritative Connection. The pinned Python child receives no OAuth
17
+ client secret or unrelated daemon environment, and permanent deletion
18
+ tools require an exact target confirmation.
19
+
13
20
  ## Links
14
21
 
15
22
  - 🌐 Website: <https://alfe.ai>
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -7,6 +8,380 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
7
8
  import { resolveConfig } from "@alfe.ai/config";
8
9
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
10
  import { assertPatternA } from "@alfe.ai/mcp-bundler";
11
+ import { createHash } from "node:crypto";
12
+ //#region src/boundary.ts
13
+ const MAX_FORWARDED_ARGUMENT_BYTES = 1024 * 1024;
14
+ const MAX_TOOL_RESULT_BYTES = 5 * 1024 * 1024;
15
+ const MAX_SCHEMA_BYTES = 512 * 1024;
16
+ const MAX_CHECK_RESPONSE_BYTES = 64 * 1024;
17
+ const MAX_JSON_DEPTH = 10;
18
+ const MAX_JSON_NODES = 2e4;
19
+ const MAX_JSON_ARRAY_ITEMS = 1e3;
20
+ const MAX_ARGUMENT_STRING_CHARS = 25e4;
21
+ const MAX_TOKEN_CHARS = 16384;
22
+ const MAX_ERROR_CHARS = 2048;
23
+ const ATLASSIAN_CHECK_TIMEOUT_MS = 15e3;
24
+ const CLOUD_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/u;
25
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/u;
26
+ const UNSAFE_KEYS = new Set([
27
+ "__proto__",
28
+ "constructor",
29
+ "prototype"
30
+ ]);
31
+ const RESERVED_TOOLS = new Set([
32
+ "atlassian_list_accounts",
33
+ "atlassian_check_connection",
34
+ "atlassian_refresh_token"
35
+ ]);
36
+ const CHILD_ENV_ALLOWLIST = [
37
+ "PATH",
38
+ "HOME",
39
+ "TMPDIR",
40
+ "TMP",
41
+ "TEMP",
42
+ "LANG",
43
+ "LC_ALL",
44
+ "LC_CTYPE",
45
+ "TZ",
46
+ "SSL_CERT_FILE",
47
+ "SSL_CERT_DIR",
48
+ "REQUESTS_CA_BUNDLE",
49
+ "CURL_CA_BUNDLE",
50
+ "HTTP_PROXY",
51
+ "HTTPS_PROXY",
52
+ "NO_PROXY",
53
+ "http_proxy",
54
+ "https_proxy",
55
+ "no_proxy",
56
+ "UV_CACHE_DIR",
57
+ "XDG_CACHE_HOME"
58
+ ];
59
+ const CONFIRMATION_RULES = {
60
+ jira_delete_issue: {
61
+ targetField: "issue_key",
62
+ confirmationField: "confirmIssueKey",
63
+ description: "Repeat the exact issue_key to confirm permanent Jira issue deletion."
64
+ },
65
+ jira_remove_issue_link: {
66
+ targetField: "link_id",
67
+ confirmationField: "confirmLinkId",
68
+ description: "Repeat the exact link_id to confirm removal of this Jira issue link."
69
+ },
70
+ confluence_delete_page: {
71
+ targetField: "page_id",
72
+ confirmationField: "confirmPageId",
73
+ description: "Repeat the exact page_id to confirm permanent Confluence page deletion."
74
+ },
75
+ confluence_delete_attachment: {
76
+ targetField: "attachment_id",
77
+ confirmationField: "confirmAttachmentId",
78
+ description: "Repeat the exact attachment_id to confirm permanent deletion of the attachment and all versions."
79
+ }
80
+ };
81
+ function buildChildEnvironment(accessToken, cloudId, source = process.env) {
82
+ const token = validateAccessToken(accessToken);
83
+ const validatedCloudId = validateCloudId(cloudId);
84
+ const environment = Object.create(null);
85
+ for (const name of CHILD_ENV_ALLOWLIST) {
86
+ const value = source[name];
87
+ if (value !== void 0 && !containsControlCharacter(value)) environment[name] = value;
88
+ }
89
+ return {
90
+ ...environment,
91
+ PYTHONUNBUFFERED: "1",
92
+ ATLASSIAN_OAUTH_ENABLE: "true",
93
+ ATLASSIAN_OAUTH_ACCESS_TOKEN: token,
94
+ ATLASSIAN_OAUTH_CLOUD_ID: validatedCloudId
95
+ };
96
+ }
97
+ function injectCloudIdSelector(tool) {
98
+ const name = validateToolName(tool.name);
99
+ if (RESERVED_TOOLS.has(name)) throw new Error(`Child tool collides with reserved proxy tool: ${name}`);
100
+ const original = cloneJsonRecord("tool input schema", tool.inputSchema, MAX_SCHEMA_BYTES, 1e5);
101
+ const propertiesValue = Reflect.get(original, "properties");
102
+ const originalProperties = propertiesValue === void 0 ? Object.create(null) : cloneJsonRecord("tool schema properties", propertiesValue, MAX_SCHEMA_BYTES, 1e5);
103
+ const originalRequired = validateRequired(Reflect.get(original, "required"));
104
+ const confirmation = CONFIRMATION_RULES[name];
105
+ const properties = {
106
+ ...originalProperties,
107
+ cloudId: {
108
+ type: "string",
109
+ pattern: CLOUD_ID_PATTERN.source,
110
+ maxLength: 128,
111
+ description: "Atlassian Cloud site ID from atlassian_list_accounts. This required selector chooses the exact Jira/Confluence site."
112
+ }
113
+ };
114
+ const required = new Set(["cloudId", ...originalRequired]);
115
+ if (confirmation !== void 0) {
116
+ properties[confirmation.confirmationField] = {
117
+ type: "string",
118
+ maxLength: 256,
119
+ description: confirmation.description
120
+ };
121
+ required.add(confirmation.confirmationField);
122
+ }
123
+ const result = {
124
+ name,
125
+ inputSchema: {
126
+ ...original,
127
+ type: "object",
128
+ properties,
129
+ required: [...required]
130
+ }
131
+ };
132
+ 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);
134
+ if (tool.outputSchema !== void 0) result.outputSchema = cloneJsonRecord("tool output schema", tool.outputSchema, MAX_SCHEMA_BYTES, 1e5);
135
+ if (tool.annotations !== void 0) result.annotations = validateAnnotations(tool.annotations);
136
+ if (tool.execution !== void 0) {
137
+ if (!isRecord(tool.execution)) throw new Error("tool execution metadata must be an object");
138
+ const taskSupport = Reflect.get(tool.execution, "taskSupport");
139
+ if (taskSupport !== void 0 && taskSupport !== "optional" && taskSupport !== "required" && taskSupport !== "forbidden") throw new Error("tool execution taskSupport is invalid");
140
+ if (taskSupport !== void 0) result.execution = { taskSupport };
141
+ }
142
+ return result;
143
+ }
144
+ function validateToolCatalog(tools) {
145
+ if (tools.length > 250) throw new Error(`Child tool catalog exceeds ${String(250)} tools`);
146
+ const names = /* @__PURE__ */ new Set();
147
+ return tools.map((tool) => {
148
+ const proxied = injectCloudIdSelector(tool);
149
+ if (names.has(proxied.name)) throw new Error(`Child tool catalog contains duplicate name: ${proxied.name}`);
150
+ names.add(proxied.name);
151
+ return proxied;
152
+ });
153
+ }
154
+ function prepareForwardedArguments(toolName, args) {
155
+ const cloned = cloneJsonRecord("tool arguments", args, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
156
+ validateCloudId(readRequiredString(cloned, "cloudId"));
157
+ const confirmation = CONFIRMATION_RULES[toolName];
158
+ if (confirmation !== void 0) {
159
+ const target = readRequiredString(cloned, confirmation.targetField);
160
+ if (readRequiredString(cloned, confirmation.confirmationField) !== target) throw new Error(`${confirmation.confirmationField} must exactly match ${confirmation.targetField}`);
161
+ }
162
+ const proxyOnlyFields = new Set(["cloudId"]);
163
+ if (confirmation !== void 0) proxyOnlyFields.add(confirmation.confirmationField);
164
+ return Object.fromEntries(Object.entries(cloned).filter(([key]) => !proxyOnlyFields.has(key)));
165
+ }
166
+ function assertBoundedToolArguments(value) {
167
+ return cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
168
+ }
169
+ function assertBoundedToolResult(value) {
170
+ assertJsonBudget("child tool result", value, MAX_TOOL_RESULT_BYTES, MAX_TOOL_RESULT_BYTES);
171
+ return value;
172
+ }
173
+ function extractToolErrorText(value) {
174
+ if (!isRecord(value) || !Array.isArray(value.content)) return "";
175
+ return value.content.slice(0, 100).map((content) => isRecord(content) && typeof content.text === "string" ? content.text.slice(0, MAX_ERROR_CHARS) : "").join("\n").slice(0, MAX_ERROR_CHARS);
176
+ }
177
+ async function checkAtlassianConnection(accessToken, fetchFn = fetch) {
178
+ const response = await fetchFn("https://api.atlassian.com/me", {
179
+ headers: {
180
+ Authorization: `Bearer ${validateAccessToken(accessToken)}`,
181
+ Accept: "application/json"
182
+ },
183
+ redirect: "error",
184
+ signal: AbortSignal.timeout(ATLASSIAN_CHECK_TIMEOUT_MS)
185
+ });
186
+ if (!response.ok) {
187
+ await response.body?.cancel().catch(() => void 0);
188
+ return {
189
+ ok: false,
190
+ status: response.status
191
+ };
192
+ }
193
+ const text = await readResponseText(response, MAX_CHECK_RESPONSE_BYTES);
194
+ const parsed = JSON.parse(text);
195
+ assertJsonBudget("Atlassian /me response", parsed, MAX_CHECK_RESPONSE_BYTES, MAX_CHECK_RESPONSE_BYTES);
196
+ return {
197
+ ok: true,
198
+ status: response.status,
199
+ user: parsed
200
+ };
201
+ }
202
+ function validateCloudId(value) {
203
+ if (!CLOUD_ID_PATTERN.test(value)) throw new Error("cloudId must be 1 to 128 letters, digits, or hyphens");
204
+ return value;
205
+ }
206
+ function validateAccountIdentifier(value) {
207
+ return validateText("accountIdentifier", value, 320);
208
+ }
209
+ function validateDisplayName(value) {
210
+ return validateText("display name", value, 256);
211
+ }
212
+ function validateSiteName(value) {
213
+ return validateText("site name", value, 256);
214
+ }
215
+ function validateConnectedAt(value) {
216
+ const validated = validateText("connectedAt", value, 64);
217
+ const parsed = new Date(validated);
218
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== validated) throw new Error("connectedAt must be a canonical ISO date-time");
219
+ return validated;
220
+ }
221
+ function validateSiteUrl(value) {
222
+ let parsed;
223
+ try {
224
+ parsed = new URL(value);
225
+ } catch {
226
+ throw new Error("site URL must be a canonical Atlassian Cloud origin");
227
+ }
228
+ if (parsed.protocol !== "https:" || !parsed.hostname.endsWith(".atlassian.net") || parsed.hostname === ".atlassian.net" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "") throw new Error("site URL must be a canonical https://<site>.atlassian.net origin");
229
+ return parsed.origin;
230
+ }
231
+ function safeErrorMessage(error, secrets = []) {
232
+ let message = error instanceof Error ? error.message : String(error);
233
+ for (const secret of secrets) if (secret.length > 0) message = message.split(secret).join("[REDACTED]");
234
+ return message.replace(/Bearer\s+[^\s,;]+/giu, "Bearer [REDACTED]").replace(/alfe_(?:dev|test|demo|live)_[a-f0-9]{16,}/giu, "[REDACTED_ALFE_KEY]").slice(0, MAX_ERROR_CHARS);
235
+ }
236
+ function connectionLogId(accountIdentifier) {
237
+ return createHash("sha256").update(accountIdentifier).digest("hex").slice(0, 12);
238
+ }
239
+ /**
240
+ * Claim a cloud selector for exactly one Connection for this process.
241
+ * A failed child spawn does not release the claim: refresh recovery must
242
+ * remain bound to the first authoritative owner instead of letting a later
243
+ * duplicate Connection take over the selector.
244
+ */
245
+ function claimCloudId(claims, cloudId, accountIdentifier) {
246
+ const id = validateCloudId(cloudId);
247
+ const owner = validateAccountIdentifier(accountIdentifier);
248
+ if (claims.has(id)) return false;
249
+ claims.set(id, owner);
250
+ return true;
251
+ }
252
+ function validateAccessToken(value) {
253
+ if (value.length < 1 || value.length > MAX_TOKEN_CHARS || containsControlCharacter(value)) throw new Error("Atlassian access token is invalid");
254
+ return value;
255
+ }
256
+ function validateToolName(value) {
257
+ if (!TOOL_NAME_PATTERN.test(value)) throw new Error("Child tool name is invalid");
258
+ return value;
259
+ }
260
+ function validateText(label, value, maxChars) {
261
+ if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
262
+ return value;
263
+ }
264
+ function validateRequired(value) {
265
+ if (value === void 0) return [];
266
+ if (!Array.isArray(value) || value.length > 250) throw new Error("tool schema required must be an array");
267
+ const result = [];
268
+ for (const entry of value) {
269
+ if (typeof entry !== "string" || entry.length < 1 || entry.length > 128) throw new Error("tool schema required contains an invalid property name");
270
+ if (!result.includes(entry)) result.push(entry);
271
+ }
272
+ return result;
273
+ }
274
+ function validateAnnotations(value) {
275
+ if (!isRecord(value)) throw new Error("tool annotations must be an object");
276
+ const result = {};
277
+ const title = value.title;
278
+ if (title !== void 0) {
279
+ if (typeof title !== "string") throw new Error("tool annotation title must be a string");
280
+ result.title = validateText("tool annotation title", title, 256);
281
+ }
282
+ for (const key of [
283
+ "readOnlyHint",
284
+ "destructiveHint",
285
+ "idempotentHint",
286
+ "openWorldHint"
287
+ ]) {
288
+ const hint = value[key];
289
+ if (hint !== void 0) {
290
+ if (typeof hint !== "boolean") throw new Error(`tool annotation ${key} must be boolean`);
291
+ result[key] = hint;
292
+ }
293
+ }
294
+ return result;
295
+ }
296
+ function readRequiredString(value, key) {
297
+ const candidate = value[key];
298
+ if (typeof candidate !== "string" || candidate.length < 1 || candidate.length > 256) throw new Error(`${key} must be a non-empty string of at most 256 characters`);
299
+ return candidate;
300
+ }
301
+ async function readResponseText(response, maxBytes) {
302
+ const declared = response.headers.get("content-length");
303
+ if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > maxBytes)) {
304
+ await response.body?.cancel().catch(() => void 0);
305
+ throw new Error("Atlassian /me response exceeds the byte limit");
306
+ }
307
+ if (response.body === null) return "";
308
+ const reader = response.body.getReader();
309
+ const decoder = new TextDecoder();
310
+ let bytes = 0;
311
+ let text = "";
312
+ try {
313
+ for (;;) {
314
+ const readResult = await reader.read();
315
+ if (readResult.done) break;
316
+ const chunk = readResult.value;
317
+ if (chunk === void 0) throw new Error("Atlassian /me response stream returned no bytes");
318
+ bytes += chunk.byteLength;
319
+ if (bytes > maxBytes) {
320
+ await reader.cancel("response too large").catch(() => void 0);
321
+ throw new Error("Atlassian /me response exceeds the byte limit");
322
+ }
323
+ text += decoder.decode(chunk, { stream: true });
324
+ }
325
+ text += decoder.decode();
326
+ return text;
327
+ } finally {
328
+ reader.releaseLock();
329
+ }
330
+ }
331
+ function cloneJsonRecord(label, value, maxBytes, maxStringChars) {
332
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
333
+ const cloned = cloneRecord(label, value, 0, { nodes: 0 }, maxStringChars);
334
+ if (Buffer.byteLength(JSON.stringify(cloned), "utf8") > maxBytes) throw new Error(`${label} exceeds the ${String(maxBytes)} byte limit`);
335
+ return cloned;
336
+ }
337
+ function assertJsonBudget(label, value, maxBytes, maxStringChars) {
338
+ cloneJson(label, value, 0, { nodes: 0 }, maxStringChars);
339
+ let encoded;
340
+ try {
341
+ encoded = JSON.stringify(value);
342
+ } catch {
343
+ throw new Error(`${label} must be JSON serializable`);
344
+ }
345
+ if (Buffer.byteLength(encoded, "utf8") > maxBytes) throw new Error(`${label} exceeds the ${String(maxBytes)} byte limit`);
346
+ }
347
+ function cloneJson(label, value, depth, budget, maxStringChars) {
348
+ budget.nodes += 1;
349
+ if (budget.nodes > MAX_JSON_NODES) throw new Error(`${label} contains too many values`);
350
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds the JSON depth limit`);
351
+ if (value === null || typeof value === "boolean") return value;
352
+ if (typeof value === "number") {
353
+ if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
354
+ return value;
355
+ }
356
+ if (typeof value === "string") {
357
+ if (value.length > maxStringChars) throw new Error(`${label} contains an oversized string`);
358
+ return value;
359
+ }
360
+ if (Array.isArray(value)) {
361
+ if (value.length > MAX_JSON_ARRAY_ITEMS) throw new Error(`${label} contains an oversized array`);
362
+ return value.map((entry) => cloneJson(label, entry, depth + 1, budget, maxStringChars));
363
+ }
364
+ if (!isRecord(value)) throw new Error(`${label} contains a non-JSON value`);
365
+ return cloneRecord(label, value, depth, budget, maxStringChars);
366
+ }
367
+ function cloneRecord(label, value, depth, budget, maxStringChars) {
368
+ const result = Object.create(null);
369
+ for (const [key, nested] of Object.entries(value)) {
370
+ if (key.length < 1 || key.length > 256 || UNSAFE_KEYS.has(key)) throw new Error(`${label} contains an unsafe property name`);
371
+ result[key] = cloneJson(label, nested, depth + 1, budget, maxStringChars);
372
+ }
373
+ return result;
374
+ }
375
+ function isRecord(value) {
376
+ return typeof value === "object" && value !== null && !Array.isArray(value);
377
+ }
378
+ function containsControlCharacter(value) {
379
+ return Array.from(value).some((character) => {
380
+ const codePoint = character.codePointAt(0) ?? 0;
381
+ return codePoint < 32 || codePoint === 127;
382
+ });
383
+ }
384
+ //#endregion
10
385
  //#region src/server.ts
11
386
  /**
12
387
  * Atlassian MCP Proxy Server (Pattern A multi-site)
@@ -44,17 +419,29 @@ import { assertPatternA } from "@alfe.ai/mcp-bundler";
44
419
  * Uses the low-level Server class (not McpServer) because child tools
45
420
  * return JSON Schema objects — McpServer.registerTool requires Zod.
46
421
  */
422
+ const packageMetadata = createRequire(import.meta.url)("../package.json");
47
423
  /** Per-site child cache. Keyed by cloudId (the Pattern A selector). */
48
424
  const sites = /* @__PURE__ */ new Map();
49
425
  /** Per-Connection bookkeeping for refresh fan-out. Keyed by accountIdentifier (email). */
50
426
  const connections = /* @__PURE__ */ new Map();
427
+ /** Authoritative first-owner claim for every Pattern A selector. */
428
+ const cloudOwners = /* @__PURE__ */ new Map();
51
429
  /**
52
430
  * Full snapshot of every (Connection × site) row, including ones we
53
431
  * couldn't spawn a child for. `atlassian_list_accounts` returns this so
54
432
  * the LLM can surface partial-failure rows.
55
433
  */
56
434
  const allSitesSnapshot = [];
435
+ const siteSummaryIndexes = /* @__PURE__ */ new Map();
57
436
  let cachedTools = [];
437
+ let childCatalogLoaded = false;
438
+ let catalogLoadPromise = null;
439
+ let proxyServer = null;
440
+ const META_TOOL_NAMES = new Set([
441
+ "atlassian_list_accounts",
442
+ "atlassian_check_connection",
443
+ "atlassian_refresh_token"
444
+ ]);
58
445
  let refreshTimer = null;
59
446
  /**
60
447
  * Collapse concurrent refreshes on a per-Connection basis. A burst of
@@ -63,6 +450,7 @@ let refreshTimer = null;
63
450
  const inFlightRefreshByConnection = /* @__PURE__ */ new Map();
64
451
  const REFRESH_INTERVAL_MS = 2700 * 1e3;
65
452
  const REFRESH_RETRY_MS = 60 * 1e3;
453
+ const CHILD_START_TIMEOUT_MS = 3e4;
66
454
  /**
67
455
  * Pin the child mcp-atlassian version. The package is distributed via
68
456
  * PyPI / uvx, so `uvx mcp-atlassian@<version>` resolves a pinned wheel.
@@ -70,50 +458,65 @@ const REFRESH_RETRY_MS = 60 * 1e3;
70
458
  * this string is the single source of truth.
71
459
  */
72
460
  const CHILD_MCP_ATLASSIAN_VERSION = "0.21.1";
73
- const ATLASSIAN_SCOPE = "read:jira-user read:jira-work write:jira-work read:confluence-content.all write:confluence-content offline_access";
74
461
  function log(msg) {
75
462
  process.stderr.write(`[atlassian-mcp-proxy] ${msg}\n`);
76
463
  }
77
464
  /**
78
- * Upsert a row in `allSitesSnapshot`, keyed by (ownerAccountIdentifier,
79
- * cloudId). Used so refresh respawn outcomes overwrite the startup row
465
+ * Upsert a row in `allSitesSnapshot`, keyed internally by Connection and
466
+ * cloudId. The private account identifier is never serialized to the LLM.
467
+ * Used so refresh respawn outcomes overwrite the startup row
80
468
  * rather than appending a stale duplicate.
81
469
  */
82
- function upsertSiteSummary(summary) {
83
- const idx = allSitesSnapshot.findIndex((s) => s.cloudId === summary.cloudId && s.ownerAccountIdentifier === summary.ownerAccountIdentifier);
84
- if (idx >= 0) allSitesSnapshot[idx] = summary;
85
- else allSitesSnapshot.push(summary);
470
+ function upsertSiteSummary(accountIdentifier, summary) {
471
+ const key = `${accountIdentifier}\u0000${summary.cloudId ?? ""}`;
472
+ const idx = siteSummaryIndexes.get(key);
473
+ if (idx !== void 0) allSitesSnapshot[idx] = summary;
474
+ else {
475
+ siteSummaryIndexes.set(key, allSitesSnapshot.length);
476
+ allSitesSnapshot.push(summary);
477
+ }
86
478
  }
87
479
  function resolveSite(cloudId) {
88
480
  if (!cloudId) throw new Error("Missing required cloudId argument. Call atlassian_list_accounts to see the connected Atlassian sites and pass the cloudId you want to target.");
89
- const site = sites.get(cloudId);
481
+ const validatedCloudId = validateCloudId(cloudId);
482
+ const site = sites.get(validatedCloudId);
90
483
  if (!site) {
91
- const known = allSitesSnapshot.find((s) => s.cloudId === cloudId);
92
- if (known && !known.connected) throw new Error(`cloudId ${cloudId} is connected on this agent but the proxy could not initialise a child server for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Atlassian account from the dashboard.`);
93
- throw new Error(`Unknown cloudId: ${cloudId}. Call atlassian_list_accounts to see the connected Atlassian sites on this agent.`);
484
+ const known = allSitesSnapshot.find((s) => s.cloudId === validatedCloudId);
485
+ if (known && !known.connected) throw new Error(`cloudId ${validatedCloudId} is connected on this agent but the proxy could not initialise a child server for it (reason: ${known.reason ?? "unknown"}). Try atlassian_refresh_token for this cloudId; if that fails ask the user to reconnect this Atlassian account.`);
486
+ throw new Error(`Unknown cloudId: ${validatedCloudId}. Call atlassian_list_accounts to see the connected Atlassian sites on this agent.`);
94
487
  }
95
488
  return site;
96
489
  }
97
- async function spawnChild(accessToken, cloudId, clientId, clientSecret) {
490
+ function resolveConnectionForCloudId(cloudId) {
491
+ if (!cloudId) throw new Error("Missing required cloudId argument. Call atlassian_list_accounts first.");
492
+ const validatedCloudId = validateCloudId(cloudId);
493
+ const owner = cloudOwners.get(validatedCloudId);
494
+ if (!owner) throw new Error(`Unknown cloudId: ${validatedCloudId}. Call atlassian_list_accounts first.`);
495
+ const connection = connections.get(owner);
496
+ if (!connection) throw new Error("Owning Connection is unavailable in proxy bookkeeping");
497
+ return connection;
498
+ }
499
+ async function spawnChild(accessToken, cloudId) {
98
500
  const transport = new StdioClientTransport({
99
501
  command: "uvx",
100
502
  args: [`mcp-atlassian@${CHILD_MCP_ATLASSIAN_VERSION}`],
101
- env: {
102
- ...process.env,
103
- ATLASSIAN_OAUTH_ENABLE: "true",
104
- ATLASSIAN_OAUTH_ACCESS_TOKEN: accessToken,
105
- ATLASSIAN_OAUTH_CLOUD_ID: cloudId,
106
- ATLASSIAN_OAUTH_CLIENT_ID: clientId,
107
- ATLASSIAN_OAUTH_CLIENT_SECRET: clientSecret,
108
- ATLASSIAN_OAUTH_SCOPE: ATLASSIAN_SCOPE
109
- }
503
+ env: buildChildEnvironment(accessToken, cloudId)
110
504
  });
111
505
  const client = new Client({
112
506
  name: "atlassian-mcp-proxy",
113
- version: "0.1.0"
507
+ version: packageMetadata.version
114
508
  });
115
- await client.connect(transport);
116
- return client;
509
+ try {
510
+ await client.connect(transport, {
511
+ timeout: CHILD_START_TIMEOUT_MS,
512
+ signal: AbortSignal.timeout(CHILD_START_TIMEOUT_MS)
513
+ });
514
+ return client;
515
+ } catch (error) {
516
+ await transport.close().catch(() => void 0);
517
+ await client.close().catch(() => void 0);
518
+ throw error;
519
+ }
117
520
  }
118
521
  async function killAllChildren() {
119
522
  for (const site of sites.values()) try {
@@ -121,12 +524,31 @@ async function killAllChildren() {
121
524
  } catch {}
122
525
  sites.clear();
123
526
  }
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;
543
+ });
544
+ return catalogLoadPromise;
545
+ }
124
546
  /**
125
547
  * Refresh a specific Connection's access token and atomically swap every
126
548
  * child server bound to a cloudId owned by that Connection. Concurrent
127
549
  * callers for the same Connection share one in-flight refresh + respawn.
128
550
  */
129
- async function refreshConnection(apiClient, accountIdentifier, clientId, clientSecret) {
551
+ async function refreshConnection(apiClient, accountIdentifier) {
130
552
  const existing = inFlightRefreshByConnection.get(accountIdentifier);
131
553
  if (existing) return existing;
132
554
  const promise = (async () => {
@@ -136,9 +558,16 @@ async function refreshConnection(apiClient, accountIdentifier, clientId, clientS
136
558
  if (!conn) throw new Error(`refreshAtlassianAccountToken succeeded for ${accountIdentifier} but no in-memory Connection bookkeeping exists`);
137
559
  const nextLive = /* @__PURE__ */ new Set();
138
560
  for (const intended of conn.intendedSites) {
561
+ if (cloudOwners.get(intended.id) !== accountIdentifier) {
562
+ log(`Refusing refresh respawn for an unowned selector on Connection ${connectionLogId(accountIdentifier)}`);
563
+ continue;
564
+ }
139
565
  const old = sites.get(intended.id);
140
566
  try {
141
- const newChild = await spawnChild(accessToken, intended.id, clientId, clientSecret);
567
+ const newChild = await spawnChild(accessToken, intended.id);
568
+ await ensureChildToolCatalog(newChild, true).catch((error) => {
569
+ log(`Could not refresh the Atlassian tool catalog: ${safeErrorMessage(error, [accessToken])}`);
570
+ });
142
571
  sites.set(intended.id, {
143
572
  cloudId: intended.id,
144
573
  siteName: intended.name,
@@ -147,11 +576,10 @@ async function refreshConnection(apiClient, accountIdentifier, clientId, clientS
147
576
  client: newChild
148
577
  });
149
578
  nextLive.add(intended.id);
150
- upsertSiteSummary({
579
+ upsertSiteSummary(accountIdentifier, {
151
580
  cloudId: intended.id,
152
581
  siteName: intended.name,
153
582
  siteUrl: intended.url,
154
- ownerAccountIdentifier: accountIdentifier,
155
583
  ownerDisplayName: conn.displayName,
156
584
  connectedAt: conn.connectedAt,
157
585
  connected: true
@@ -160,19 +588,18 @@ async function refreshConnection(apiClient, accountIdentifier, clientId, clientS
160
588
  await old.client.close();
161
589
  } catch {}
162
590
  } catch (spawnErr) {
163
- const message = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
164
- log(`Token refresh succeeded for Connection ${accountIdentifier} but re-spawn failed for cloudId ${intended.id}: ${message} — evicting stale child`);
591
+ const message = safeErrorMessage(spawnErr, [accessToken, conn.accessToken]);
592
+ log(`Token refresh succeeded for Connection ${connectionLogId(accountIdentifier)} but a child re-spawn failed: ${message} — evicting stale child`);
165
593
  if (old) {
166
594
  sites.delete(intended.id);
167
595
  try {
168
596
  await old.client.close();
169
597
  } catch {}
170
598
  }
171
- upsertSiteSummary({
599
+ upsertSiteSummary(accountIdentifier, {
172
600
  cloudId: intended.id,
173
601
  siteName: intended.name,
174
602
  siteUrl: intended.url,
175
- ownerAccountIdentifier: accountIdentifier,
176
603
  ownerDisplayName: conn.displayName,
177
604
  connectedAt: conn.connectedAt,
178
605
  connected: false,
@@ -190,58 +617,31 @@ async function refreshConnection(apiClient, accountIdentifier, clientId, clientS
190
617
  inFlightRefreshByConnection.set(accountIdentifier, promise);
191
618
  return promise;
192
619
  }
193
- function scheduleRefresh(apiClient, clientId, clientSecret) {
620
+ function scheduleRefresh(apiClient, delayMs = REFRESH_INTERVAL_MS) {
194
621
  if (refreshTimer) clearTimeout(refreshTimer);
195
622
  refreshTimer = setTimeout(() => {
196
623
  (async () => {
197
624
  const ids = Array.from(connections.keys());
198
625
  if (ids.length === 0) {
199
- refreshTimer = setTimeout(() => {
200
- scheduleRefresh(apiClient, clientId, clientSecret);
201
- }, REFRESH_INTERVAL_MS);
626
+ scheduleRefresh(apiClient);
202
627
  return;
203
628
  }
204
629
  log(`Refreshing Atlassian access tokens across ${String(ids.length)} Connection(s)...`);
205
630
  let anyFailed = false;
206
631
  for (const id of ids) try {
207
- await refreshConnection(apiClient, id, clientId, clientSecret);
632
+ await refreshConnection(apiClient, id);
208
633
  } catch (err) {
209
634
  anyFailed = true;
210
- log(`Token refresh failed for Connection ${id}: ${err instanceof Error ? err.message : String(err)}`);
635
+ const connection = connections.get(id);
636
+ log(`Token refresh failed for Connection ${connectionLogId(id)}: ${safeErrorMessage(err, connection ? [connection.accessToken] : [])}`);
211
637
  }
212
- refreshTimer = setTimeout(() => {
213
- scheduleRefresh(apiClient, clientId, clientSecret);
214
- }, anyFailed ? REFRESH_RETRY_MS : REFRESH_INTERVAL_MS);
215
- })();
216
- }, REFRESH_INTERVAL_MS);
217
- }
218
- /**
219
- * Inject a required `cloudId` string property into a child tool's
220
- * inputSchema. The proxy strips this arg before forwarding to the
221
- * matching child server.
222
- */
223
- function injectCloudIdSelector(tool) {
224
- const original = tool.inputSchema ?? {};
225
- const originalProperties = original.properties ?? {};
226
- const originalRequired = Array.isArray(original.required) ? original.required : [];
227
- const injectedProperties = {
228
- ...originalProperties,
229
- cloudId: {
230
- type: "string",
231
- description: "Atlassian Cloud site ID — use the value from atlassian_list_accounts to pick which connected site (Jira instance / Confluence instance) this call should target. One OAuth user may have access to multiple sites."
232
- }
233
- };
234
- const injectedRequired = originalRequired.includes("cloudId") ? originalRequired : ["cloudId", ...originalRequired];
235
- return {
236
- name: tool.name,
237
- description: tool.description,
238
- inputSchema: {
239
- ...original,
240
- type: original.type ?? "object",
241
- properties: injectedProperties,
242
- required: injectedRequired
243
- }
244
- };
638
+ scheduleRefresh(apiClient, anyFailed ? REFRESH_RETRY_MS : REFRESH_INTERVAL_MS);
639
+ })().catch((error) => {
640
+ log(`Scheduled refresh loop failed: ${safeErrorMessage(error)}`);
641
+ scheduleRefresh(apiClient, REFRESH_RETRY_MS);
642
+ });
643
+ }, delayMs);
644
+ refreshTimer.unref();
245
645
  }
246
646
  async function main() {
247
647
  const config = resolveConfig();
@@ -250,141 +650,140 @@ async function main() {
250
650
  apiUrl: config.apiUrl
251
651
  });
252
652
  const { accounts } = await apiClient.getAtlassianAccounts();
653
+ if (accounts.length > 50) throw new Error(`Connect returned more than ${String(50)} Atlassian Connections`);
253
654
  if (accounts.length === 0) log("No Atlassian Connections found — proxy will start with atlassian_list_accounts and atlassian_check_connection only");
254
- let sharedClientId = "";
255
- let sharedClientSecret = "";
256
- for (const acct of accounts) if (acct.clientId && acct.clientSecret) {
257
- sharedClientId = acct.clientId;
258
- sharedClientSecret = acct.clientSecret;
259
- break;
260
- }
655
+ let totalSites = 0;
261
656
  for (const acct of accounts) {
262
- const intendedSitesForAcct = acct.availableSites.length > 0 ? acct.availableSites.map((s) => ({
263
- id: s.id,
264
- name: s.name,
265
- url: s.url
266
- })) : acct.cloudId ? [{
267
- id: acct.cloudId,
268
- name: acct.siteName,
269
- url: acct.siteUrl
270
- }] : [];
271
- if (!acct.accessToken) {
272
- log(`Skipping Connection ${acct.accountIdentifier} — no access token`);
273
- for (const site of intendedSitesForAcct) upsertSiteSummary({
274
- cloudId: site.id,
275
- siteName: site.name,
276
- siteUrl: site.url,
277
- ownerAccountIdentifier: acct.accountIdentifier,
278
- ownerDisplayName: acct.displayName,
279
- connectedAt: acct.connectedAt,
280
- connected: false,
281
- reason: "missing_access_token"
282
- });
657
+ let accountIdentifier;
658
+ let displayName;
659
+ let connectedAt;
660
+ let intendedSitesForAcct;
661
+ try {
662
+ accountIdentifier = validateAccountIdentifier(acct.accountIdentifier);
663
+ displayName = acct.displayName === null ? null : validateDisplayName(acct.displayName);
664
+ connectedAt = validateConnectedAt(acct.connectedAt);
665
+ const rawSites = acct.availableSites.length > 0 ? acct.availableSites : acct.cloudId && acct.siteName && acct.siteUrl ? [{
666
+ id: acct.cloudId,
667
+ name: acct.siteName,
668
+ url: acct.siteUrl
669
+ }] : [];
670
+ if (rawSites.length > 100) throw new Error(`Connection exceeds ${String(100)} accessible sites`);
671
+ intendedSitesForAcct = rawSites.map((site) => ({
672
+ id: validateCloudId(site.id),
673
+ name: validateSiteName(site.name),
674
+ url: validateSiteUrl(site.url)
675
+ }));
676
+ totalSites += intendedSitesForAcct.length;
677
+ if (totalSites > 250) throw new Error(`Connect returned more than ${String(250)} Atlassian sites`);
678
+ } catch (error) {
679
+ log(`Skipping invalid Atlassian Connection metadata: ${safeErrorMessage(error, [acct.accessToken, acct.clientSecret])}`);
283
680
  continue;
284
681
  }
285
- if (connections.has(acct.accountIdentifier)) {
286
- log(`Duplicate accountIdentifier ${acct.accountIdentifier} returned by getAtlassianAccounts() — keeping the first cached Connection`);
287
- for (const site of intendedSitesForAcct) upsertSiteSummary({
288
- cloudId: site.id,
289
- siteName: site.name,
290
- siteUrl: site.url,
291
- ownerAccountIdentifier: acct.accountIdentifier,
292
- ownerDisplayName: acct.displayName,
293
- connectedAt: acct.connectedAt,
294
- connected: false,
295
- reason: "duplicate_accountIdentifier"
296
- });
682
+ if (connections.has(accountIdentifier)) {
683
+ log(`Duplicate Atlassian Connection ${connectionLogId(accountIdentifier)} returned by Connect — keeping the first`);
297
684
  continue;
298
685
  }
686
+ const connectionEntry = {
687
+ accountIdentifier,
688
+ displayName,
689
+ connectedAt,
690
+ accessToken: acct.accessToken,
691
+ intendedSites: [],
692
+ liveCloudIds: /* @__PURE__ */ new Set()
693
+ };
694
+ connections.set(accountIdentifier, connectionEntry);
299
695
  if (intendedSitesForAcct.length === 0) {
300
- log(`Connection ${acct.accountIdentifier} has no accessible Cloud sites — skipping (the agent will see this account in atlassian_list_accounts as connected: false)`);
301
- upsertSiteSummary({
302
- cloudId: `__no_sites__:${acct.accountIdentifier}`,
303
- siteName: acct.displayName ?? acct.accountIdentifier,
696
+ log(`Atlassian Connection ${connectionLogId(accountIdentifier)} has no accessible Cloud sites`);
697
+ upsertSiteSummary(accountIdentifier, {
698
+ cloudId: null,
699
+ siteName: displayName ?? "Atlassian account",
304
700
  siteUrl: "",
305
- ownerAccountIdentifier: acct.accountIdentifier,
306
- ownerDisplayName: acct.displayName,
307
- connectedAt: acct.connectedAt,
701
+ ownerDisplayName: displayName,
702
+ connectedAt,
308
703
  connected: false,
309
704
  reason: "no_accessible_sites"
310
705
  });
311
706
  continue;
312
707
  }
313
- const connectionEntry = {
314
- accountIdentifier: acct.accountIdentifier,
315
- displayName: acct.displayName,
316
- connectedAt: acct.connectedAt,
317
- accessToken: acct.accessToken,
318
- intendedSites: intendedSitesForAcct,
319
- liveCloudIds: /* @__PURE__ */ new Set()
320
- };
321
708
  for (const site of intendedSitesForAcct) {
322
- if (sites.has(site.id)) {
323
- log(`Duplicate cloudId ${site.id} across Atlassian Connections — keeping the first cached site`);
324
- upsertSiteSummary({
709
+ if (!claimCloudId(cloudOwners, site.id, accountIdentifier)) {
710
+ log(`Duplicate Atlassian cloudId rejected for Connection ${connectionLogId(accountIdentifier)} — keeping the first owner`);
711
+ upsertSiteSummary(accountIdentifier, {
325
712
  cloudId: site.id,
326
713
  siteName: site.name,
327
714
  siteUrl: site.url,
328
- ownerAccountIdentifier: acct.accountIdentifier,
329
- ownerDisplayName: acct.displayName,
330
- connectedAt: acct.connectedAt,
715
+ ownerDisplayName: displayName,
716
+ connectedAt,
331
717
  connected: false,
332
718
  reason: "duplicate_cloudId"
333
719
  });
334
720
  continue;
335
721
  }
722
+ connectionEntry.intendedSites.push(site);
723
+ if (!acct.accessToken) {
724
+ upsertSiteSummary(accountIdentifier, {
725
+ cloudId: site.id,
726
+ siteName: site.name,
727
+ siteUrl: site.url,
728
+ ownerDisplayName: displayName,
729
+ connectedAt,
730
+ connected: false,
731
+ reason: "missing_access_token"
732
+ });
733
+ continue;
734
+ }
336
735
  try {
337
- const child = await spawnChild(acct.accessToken, site.id, acct.clientId || sharedClientId, acct.clientSecret || sharedClientSecret);
736
+ const child = await spawnChild(acct.accessToken, site.id);
338
737
  sites.set(site.id, {
339
738
  cloudId: site.id,
340
739
  siteName: site.name,
341
740
  siteUrl: site.url,
342
- ownerAccountIdentifier: acct.accountIdentifier,
741
+ ownerAccountIdentifier: accountIdentifier,
343
742
  client: child
344
743
  });
345
744
  connectionEntry.liveCloudIds.add(site.id);
346
- upsertSiteSummary({
745
+ upsertSiteSummary(accountIdentifier, {
347
746
  cloudId: site.id,
348
747
  siteName: site.name,
349
748
  siteUrl: site.url,
350
- ownerAccountIdentifier: acct.accountIdentifier,
351
- ownerDisplayName: acct.displayName,
352
- connectedAt: acct.connectedAt,
749
+ ownerDisplayName: displayName,
750
+ connectedAt,
353
751
  connected: true
354
752
  });
355
- log(`Spawned child for site ${site.id} (${site.name}) under Connection ${acct.accountIdentifier}`);
753
+ log(`Spawned Atlassian child for Connection ${connectionLogId(accountIdentifier)}`);
356
754
  } catch (err) {
357
- const message = err instanceof Error ? err.message : String(err);
358
- log(`Failed to spawn child for site ${site.id} (${site.name}) under Connection ${acct.accountIdentifier}: ${message}`);
359
- upsertSiteSummary({
755
+ const message = safeErrorMessage(err, [acct.accessToken, acct.clientSecret]);
756
+ log(`Failed to spawn Atlassian child for Connection ${connectionLogId(accountIdentifier)}: ${message}`);
757
+ upsertSiteSummary(accountIdentifier, {
360
758
  cloudId: site.id,
361
759
  siteName: site.name,
362
760
  siteUrl: site.url,
363
- ownerAccountIdentifier: acct.accountIdentifier,
364
- ownerDisplayName: acct.displayName,
365
- connectedAt: acct.connectedAt,
761
+ ownerDisplayName: displayName,
762
+ connectedAt,
366
763
  connected: false,
367
764
  reason: `spawn_failed: ${message}`
368
765
  });
369
766
  }
370
767
  }
371
- connections.set(acct.accountIdentifier, connectionEntry);
372
768
  }
373
769
  const firstSite = sites.values().next().value;
374
- if (firstSite) {
375
- const { tools } = await firstSite.client.listTools();
376
- cachedTools = tools.map(injectCloudIdSelector);
377
- log(`Child MCP server provides ${String(cachedTools.length)} tools (cloudId selector injected)`);
378
- } else {
770
+ if (firstSite) await ensureChildToolCatalog(firstSite.client, false);
771
+ else {
379
772
  cachedTools = [];
380
773
  log("No child server available — only atlassian_list_accounts, atlassian_check_connection, and atlassian_refresh_token will be exposed");
381
774
  }
382
775
  cachedTools.push({
383
776
  name: "atlassian_list_accounts",
384
- description: "List the Atlassian Cloud sites the agent has connected. Each entry pairs a `cloudId` (the selector value to pass on every other Atlassian tool) with its owning OAuth identity (`ownerAccountIdentifier`, an email). One OAuth user may own multiple sites — they share an access token but each has a distinct cloudId.",
777
+ description: "List the Atlassian Cloud sites the agent has connected. Each connected entry provides the cloudId selector to pass to other Atlassian tools. One OAuth user may own multiple sites.",
385
778
  inputSchema: {
386
779
  type: "object",
387
- properties: {}
780
+ properties: {},
781
+ additionalProperties: false
782
+ },
783
+ annotations: {
784
+ readOnlyHint: true,
785
+ idempotentHint: true,
786
+ openWorldHint: false
388
787
  }
389
788
  });
390
789
  cachedTools.push({
@@ -396,7 +795,13 @@ async function main() {
396
795
  type: "string",
397
796
  description: "Atlassian Cloud site ID — use the value from atlassian_list_accounts."
398
797
  } },
399
- required: ["cloudId"]
798
+ required: ["cloudId"],
799
+ additionalProperties: false
800
+ },
801
+ annotations: {
802
+ readOnlyHint: true,
803
+ idempotentHint: true,
804
+ openWorldHint: true
400
805
  }
401
806
  });
402
807
  cachedTools.push({
@@ -408,7 +813,14 @@ async function main() {
408
813
  type: "string",
409
814
  description: "Atlassian Cloud site ID — used to look up the owning Connection. Use the value from atlassian_list_accounts."
410
815
  } },
411
- required: ["cloudId"]
816
+ required: ["cloudId"],
817
+ additionalProperties: false
818
+ },
819
+ annotations: {
820
+ readOnlyHint: false,
821
+ destructiveHint: false,
822
+ idempotentHint: false,
823
+ openWorldHint: true
412
824
  }
413
825
  });
414
826
  assertPatternA(cachedTools.map((t) => ({
@@ -420,75 +832,81 @@ async function main() {
420
832
  });
421
833
  const proxy = new Server({
422
834
  name: "atlassian-mcp-proxy",
423
- version: "0.1.0"
424
- }, { capabilities: { tools: {} } });
835
+ version: packageMetadata.version
836
+ }, { capabilities: { tools: { listChanged: true } } });
837
+ proxyServer = proxy;
425
838
  proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
426
839
  proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
427
840
  const { name, arguments: args } = request.params;
428
- const argMap = args ?? {};
429
- if (name === "atlassian_list_accounts") return { content: [{
430
- type: "text",
431
- text: JSON.stringify({ sites: allSitesSnapshot }, null, 2)
432
- }] };
433
- if (name === "atlassian_check_connection") try {
434
- const site = resolveSite(typeof argMap.cloudId === "string" ? argMap.cloudId : void 0);
435
- const conn = connections.get(site.ownerAccountIdentifier);
436
- if (!conn) return {
841
+ let argMap;
842
+ try {
843
+ argMap = assertBoundedToolArguments(args ?? {});
844
+ } catch (error) {
845
+ return {
437
846
  content: [{
438
847
  type: "text",
439
- text: JSON.stringify({
440
- cloudId: site.cloudId,
441
- connected: false,
442
- error: "Owning Connection not found in proxy bookkeeping"
443
- })
848
+ text: JSON.stringify({ error: safeErrorMessage(error) })
444
849
  }],
445
850
  isError: true
446
851
  };
447
- const response = await fetch("https://api.atlassian.com/me", { headers: {
448
- Authorization: `Bearer ${conn.accessToken}`,
449
- Accept: "application/json"
450
- } });
451
- if (!response.ok) return {
852
+ }
853
+ if (name === "atlassian_list_accounts") {
854
+ if (Object.keys(argMap).length > 0) return {
855
+ content: [{
856
+ type: "text",
857
+ text: JSON.stringify({ error: "atlassian_list_accounts accepts no arguments" })
858
+ }],
859
+ isError: true
860
+ };
861
+ return { content: [{
862
+ type: "text",
863
+ text: JSON.stringify({ sites: allSitesSnapshot }, null, 2)
864
+ }] };
865
+ }
866
+ if (name === "atlassian_check_connection") try {
867
+ if (Object.keys(argMap).some((key) => key !== "cloudId")) throw new Error("atlassian_check_connection accepts only cloudId");
868
+ const cloudId = typeof argMap.cloudId === "string" ? validateCloudId(argMap.cloudId) : void 0;
869
+ const conn = resolveConnectionForCloudId(cloudId);
870
+ const site = cloudId === void 0 ? void 0 : sites.get(cloudId);
871
+ const check = await checkAtlassianConnection(conn.accessToken);
872
+ if (!check.ok) return {
452
873
  content: [{
453
874
  type: "text",
454
875
  text: JSON.stringify({
455
- cloudId: site.cloudId,
876
+ cloudId,
456
877
  connected: false,
457
- status: response.status,
458
- error: "Token may have been revoked or expired. Try atlassian_refresh_token for this cloudId; if that fails ask the user to reconnect this Atlassian account."
878
+ status: check.status,
879
+ error: "Token may have expired or been revoked. Try atlassian_refresh_token; if that fails ask the user to reconnect this account."
459
880
  })
460
881
  }],
461
882
  isError: true
462
883
  };
463
- const user = await response.json();
464
884
  return { content: [{
465
885
  type: "text",
466
886
  text: JSON.stringify({
467
- cloudId: site.cloudId,
468
- siteName: site.siteName,
469
- siteUrl: site.siteUrl,
470
- ownerAccountIdentifier: site.ownerAccountIdentifier,
471
- connected: true,
472
- user
887
+ cloudId,
888
+ siteName: site?.siteName,
889
+ siteUrl: site?.siteUrl,
890
+ connected: true
473
891
  })
474
892
  }] };
475
893
  } catch (err) {
476
894
  return {
477
895
  content: [{
478
896
  type: "text",
479
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
897
+ text: JSON.stringify({ error: safeErrorMessage(err) })
480
898
  }],
481
899
  isError: true
482
900
  };
483
901
  }
484
902
  if (name === "atlassian_refresh_token") try {
485
- const site = resolveSite(typeof argMap.cloudId === "string" ? argMap.cloudId : void 0);
486
- await refreshConnection(apiClient, site.ownerAccountIdentifier, sharedClientId, sharedClientSecret);
903
+ if (Object.keys(argMap).some((key) => key !== "cloudId")) throw new Error("atlassian_refresh_token accepts only cloudId");
904
+ const cloudId = typeof argMap.cloudId === "string" ? validateCloudId(argMap.cloudId) : void 0;
905
+ await refreshConnection(apiClient, resolveConnectionForCloudId(cloudId).accountIdentifier);
487
906
  return { content: [{
488
907
  type: "text",
489
908
  text: JSON.stringify({
490
- cloudId: site.cloudId,
491
- ownerAccountIdentifier: site.ownerAccountIdentifier,
909
+ cloudId,
492
910
  refreshed: true
493
911
  })
494
912
  }] };
@@ -496,30 +914,41 @@ async function main() {
496
914
  return {
497
915
  content: [{
498
916
  type: "text",
499
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
917
+ text: JSON.stringify({ error: safeErrorMessage(err) })
500
918
  }],
501
919
  isError: true
502
920
  };
503
921
  }
922
+ if (!cachedTools.some((tool) => tool.name === name)) return {
923
+ content: [{
924
+ type: "text",
925
+ text: JSON.stringify({ error: "Unknown Atlassian tool" })
926
+ }],
927
+ isError: true
928
+ };
504
929
  let site;
930
+ let forwarded;
505
931
  try {
506
932
  site = resolveSite(typeof argMap.cloudId === "string" ? argMap.cloudId : void 0);
933
+ forwarded = prepareForwardedArguments(name, argMap);
507
934
  } catch (err) {
508
935
  return {
509
936
  content: [{
510
937
  type: "text",
511
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
938
+ text: JSON.stringify({ error: safeErrorMessage(err) })
512
939
  }],
513
940
  isError: true
514
941
  };
515
942
  }
516
- const { cloudId: _ignored, ...forwarded } = argMap;
517
943
  let result;
518
944
  try {
519
- result = await site.client.callTool({
945
+ result = assertBoundedToolResult(await site.client.callTool({
520
946
  name,
521
947
  arguments: forwarded
522
- });
948
+ }, void 0, {
949
+ timeout: 12e4,
950
+ signal: AbortSignal.timeout(12e4)
951
+ }));
523
952
  } catch (err) {
524
953
  return {
525
954
  content: [{
@@ -527,24 +956,39 @@ async function main() {
527
956
  text: JSON.stringify({
528
957
  cloudId: site.cloudId,
529
958
  tool: name,
530
- error: err instanceof Error ? err.message : String(err)
959
+ error: safeErrorMessage(err, [connections.get(site.ownerAccountIdentifier)?.accessToken ?? ""])
531
960
  })
532
961
  }],
533
962
  isError: true
534
963
  };
535
964
  }
536
965
  if (result.isError) {
537
- const errorText = result.content.map((c) => c.text ?? "").join("\n");
966
+ const errorText = extractToolErrorText(result);
538
967
  if (errorText.includes("401") || errorText.toLowerCase().includes("unauthorized")) {
539
- log(`401 from cloudId ${site.cloudId} (owner ${site.ownerAccountIdentifier}), refreshing and retrying...`);
968
+ log(`Atlassian child returned 401 for Connection ${connectionLogId(site.ownerAccountIdentifier)}; refreshing once`);
540
969
  try {
541
- await refreshConnection(apiClient, site.ownerAccountIdentifier, sharedClientId, sharedClientSecret);
970
+ await refreshConnection(apiClient, site.ownerAccountIdentifier);
542
971
  const refreshed = resolveSite(site.cloudId);
543
972
  try {
544
- return await refreshed.client.callTool({
973
+ const retryResult = assertBoundedToolResult(await refreshed.client.callTool({
545
974
  name,
546
975
  arguments: forwarded
547
- });
976
+ }, void 0, {
977
+ timeout: 12e4,
978
+ signal: AbortSignal.timeout(12e4)
979
+ }));
980
+ if (retryResult.isError) return {
981
+ content: [{
982
+ type: "text",
983
+ text: JSON.stringify({
984
+ cloudId: site.cloudId,
985
+ tool: name,
986
+ error: safeErrorMessage(extractToolErrorText(retryResult), [connections.get(site.ownerAccountIdentifier)?.accessToken ?? ""])
987
+ })
988
+ }],
989
+ isError: true
990
+ };
991
+ return retryResult;
548
992
  } catch (retryErr) {
549
993
  return {
550
994
  content: [{
@@ -552,7 +996,7 @@ async function main() {
552
996
  text: JSON.stringify({
553
997
  cloudId: site.cloudId,
554
998
  tool: name,
555
- error: `Retry after refresh failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`
999
+ error: `Retry after refresh failed: ${safeErrorMessage(retryErr, [connections.get(site.ownerAccountIdentifier)?.accessToken ?? ""])}`
556
1000
  })
557
1001
  }],
558
1002
  isError: true
@@ -564,35 +1008,46 @@ async function main() {
564
1008
  type: "text",
565
1009
  text: JSON.stringify({
566
1010
  cloudId: site.cloudId,
567
- ownerAccountIdentifier: site.ownerAccountIdentifier,
568
- error: `Token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`
1011
+ error: `Token refresh failed: ${safeErrorMessage(refreshErr)}`
569
1012
  })
570
1013
  }],
571
1014
  isError: true
572
1015
  };
573
1016
  }
574
1017
  }
1018
+ return {
1019
+ content: [{
1020
+ type: "text",
1021
+ text: JSON.stringify({
1022
+ cloudId: site.cloudId,
1023
+ tool: name,
1024
+ error: safeErrorMessage(errorText || "Atlassian tool failed", [connections.get(site.ownerAccountIdentifier)?.accessToken ?? ""])
1025
+ })
1026
+ }],
1027
+ isError: true
1028
+ };
575
1029
  }
576
1030
  return result;
577
1031
  });
578
- if (sharedClientId && sharedClientSecret) scheduleRefresh(apiClient, sharedClientId, sharedClientSecret);
579
- else if (connections.size > 0) log("No shared OAuth client credentials available — scheduled refresh disabled. Refresh will be possible per call via the atlassian_refresh_token tool, but only if the API returns 401 explicitly.");
1032
+ if (connections.size > 0) scheduleRefresh(apiClient);
580
1033
  const transport = new StdioServerTransport();
581
1034
  await proxy.connect(transport);
582
1035
  log(`Proxy running with ${String(sites.size)} site(s) across ${String(connections.size)} Connection(s) and Pattern A selector enforcement`);
583
1036
  }
584
- for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
1037
+ let shutdownPromise = null;
1038
+ function shutdown(exitCode) {
1039
+ if (shutdownPromise !== null) return;
585
1040
  if (refreshTimer) clearTimeout(refreshTimer);
586
- killAllChildren().then(() => {
587
- process.exit(0);
1041
+ shutdownPromise = killAllChildren().finally(() => {
1042
+ process.exit(exitCode);
588
1043
  });
1044
+ }
1045
+ for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => {
1046
+ shutdown(0);
589
1047
  });
590
1048
  main().catch((err) => {
591
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
592
- if (refreshTimer) clearTimeout(refreshTimer);
593
- killAllChildren().finally(() => {
594
- process.exit(1);
595
- });
1049
+ log(`Fatal: ${safeErrorMessage(err, Array.from(connections.values(), (connection) => connection.accessToken))}`);
1050
+ shutdown(1);
596
1051
  });
597
1052
  //#endregion
598
1053
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/atlassian-mcp",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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",
@@ -18,9 +18,9 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": ">=1.24.0",
21
- "@alfe.ai/config": "0.3.0",
22
- "@alfe.ai/agent-api-client": "0.13.0",
23
- "@alfe.ai/mcp-bundler": "0.4.0"
21
+ "@alfe.ai/config": "0.4.1",
22
+ "@alfe.ai/agent-api-client": "0.15.0",
23
+ "@alfe.ai/mcp-bundler": "0.4.1"
24
24
  },
25
25
  "license": "UNLICENSED",
26
26
  "homepage": "https://alfe.ai",
@@ -36,6 +36,7 @@
36
36
  "scripts": {
37
37
  "build": "tsdown",
38
38
  "dev": "tsdown --watch",
39
+ "test": "vitest run",
39
40
  "typecheck": "tsc --noEmit",
40
41
  "lint": "eslint ."
41
42
  }