@alfe.ai/atlassian-mcp 0.3.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/dist/server.d.ts +1 -0
- package/dist/server.js +598 -0
- package/package.json +32 -0
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { resolveConfig } from "@alfe.ai/config";
|
|
8
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
9
|
+
import { assertPatternA } from "@alfe.ai/mcp-bundler";
|
|
10
|
+
//#region src/server.ts
|
|
11
|
+
/**
|
|
12
|
+
* Atlassian MCP Proxy Server (Pattern A multi-site)
|
|
13
|
+
*
|
|
14
|
+
* A thin proxy that fans out to one `sooperset/mcp-atlassian` child
|
|
15
|
+
* process per (OAuth Connection × Atlassian Cloud site). Every
|
|
16
|
+
* credential-touching tool requires the LLM to pass a `cloudId` selector
|
|
17
|
+
* arg; the proxy strips that arg, dispatches to the right child, and
|
|
18
|
+
* returns the result.
|
|
19
|
+
*
|
|
20
|
+
* Pattern A locked in PR 7-deferred slice 3 of channels-and-credential-
|
|
21
|
+
* driven-integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
|
|
22
|
+
* contract; `services/connect/DEVELOPING.md` for the provider table.
|
|
23
|
+
*
|
|
24
|
+
* Architecture (more involved than the Notion / Xero / GitHub proxies
|
|
25
|
+
* because Atlassian is multi-site PER OAuth user):
|
|
26
|
+
*
|
|
27
|
+
* OpenClaw ←(stdio)→ this proxy ←(stdio fan-out)→ N × `uvx mcp-atlassian`
|
|
28
|
+
*
|
|
29
|
+
* Each Connection (OAuth user) -> 1 access token + N accessible Cloud
|
|
30
|
+
* sites. We spawn one child per (Connection, cloudId), all sharing
|
|
31
|
+
* the Connection's access token. Refreshing one Connection rotates
|
|
32
|
+
* the access token for every cloudId it owns, so the proxy fans the
|
|
33
|
+
* new token out to every child the Connection spawned.
|
|
34
|
+
*
|
|
35
|
+
* Token refresh is per-Connection (NOT per-cloudId): the OAuth user has
|
|
36
|
+
* one refresh token that covers every site they can reach. Atlassian
|
|
37
|
+
* rotates refresh tokens — server-side persistence is handled by the
|
|
38
|
+
* /accounts/{accountIdentifier}/refresh endpoint.
|
|
39
|
+
*
|
|
40
|
+
* Atlassian access tokens are short-lived (~1 hour). The scheduled
|
|
41
|
+
* refresh walks every Connection on a 45-minute cadence to stay ahead
|
|
42
|
+
* of expiry.
|
|
43
|
+
*
|
|
44
|
+
* Uses the low-level Server class (not McpServer) because child tools
|
|
45
|
+
* return JSON Schema objects — McpServer.registerTool requires Zod.
|
|
46
|
+
*/
|
|
47
|
+
/** Per-site child cache. Keyed by cloudId (the Pattern A selector). */
|
|
48
|
+
const sites = /* @__PURE__ */ new Map();
|
|
49
|
+
/** Per-Connection bookkeeping for refresh fan-out. Keyed by accountIdentifier (email). */
|
|
50
|
+
const connections = /* @__PURE__ */ new Map();
|
|
51
|
+
/**
|
|
52
|
+
* Full snapshot of every (Connection × site) row, including ones we
|
|
53
|
+
* couldn't spawn a child for. `atlassian_list_accounts` returns this so
|
|
54
|
+
* the LLM can surface partial-failure rows.
|
|
55
|
+
*/
|
|
56
|
+
const allSitesSnapshot = [];
|
|
57
|
+
let cachedTools = [];
|
|
58
|
+
let refreshTimer = null;
|
|
59
|
+
/**
|
|
60
|
+
* Collapse concurrent refreshes on a per-Connection basis. A burst of
|
|
61
|
+
* 401s across the same Connection's sites share one in-flight refresh.
|
|
62
|
+
*/
|
|
63
|
+
const inFlightRefreshByConnection = /* @__PURE__ */ new Map();
|
|
64
|
+
const REFRESH_INTERVAL_MS = 2700 * 1e3;
|
|
65
|
+
const REFRESH_RETRY_MS = 60 * 1e3;
|
|
66
|
+
/**
|
|
67
|
+
* Pin the child mcp-atlassian version. The package is distributed via
|
|
68
|
+
* PyPI / uvx, so `uvx mcp-atlassian@<version>` resolves a pinned wheel.
|
|
69
|
+
* `package.json` doesn't have a direct dep on the Python package, so
|
|
70
|
+
* this string is the single source of truth.
|
|
71
|
+
*/
|
|
72
|
+
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
|
+
function log(msg) {
|
|
75
|
+
process.stderr.write(`[atlassian-mcp-proxy] ${msg}\n`);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Upsert a row in `allSitesSnapshot`, keyed by (ownerAccountIdentifier,
|
|
79
|
+
* cloudId). Used so refresh respawn outcomes overwrite the startup row
|
|
80
|
+
* rather than appending a stale duplicate.
|
|
81
|
+
*/
|
|
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);
|
|
86
|
+
}
|
|
87
|
+
function resolveSite(cloudId) {
|
|
88
|
+
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);
|
|
90
|
+
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.`);
|
|
94
|
+
}
|
|
95
|
+
return site;
|
|
96
|
+
}
|
|
97
|
+
async function spawnChild(accessToken, cloudId, clientId, clientSecret) {
|
|
98
|
+
const transport = new StdioClientTransport({
|
|
99
|
+
command: "uvx",
|
|
100
|
+
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
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const client = new Client({
|
|
112
|
+
name: "atlassian-mcp-proxy",
|
|
113
|
+
version: "0.1.0"
|
|
114
|
+
});
|
|
115
|
+
await client.connect(transport);
|
|
116
|
+
return client;
|
|
117
|
+
}
|
|
118
|
+
async function killAllChildren() {
|
|
119
|
+
for (const site of sites.values()) try {
|
|
120
|
+
await site.client.close();
|
|
121
|
+
} catch {}
|
|
122
|
+
sites.clear();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Refresh a specific Connection's access token and atomically swap every
|
|
126
|
+
* child server bound to a cloudId owned by that Connection. Concurrent
|
|
127
|
+
* callers for the same Connection share one in-flight refresh + respawn.
|
|
128
|
+
*/
|
|
129
|
+
async function refreshConnection(apiClient, accountIdentifier, clientId, clientSecret) {
|
|
130
|
+
const existing = inFlightRefreshByConnection.get(accountIdentifier);
|
|
131
|
+
if (existing) return existing;
|
|
132
|
+
const promise = (async () => {
|
|
133
|
+
try {
|
|
134
|
+
const { accessToken } = await apiClient.refreshAtlassianAccountToken(accountIdentifier);
|
|
135
|
+
const conn = connections.get(accountIdentifier);
|
|
136
|
+
if (!conn) throw new Error(`refreshAtlassianAccountToken succeeded for ${accountIdentifier} but no in-memory Connection bookkeeping exists`);
|
|
137
|
+
const nextLive = /* @__PURE__ */ new Set();
|
|
138
|
+
for (const intended of conn.intendedSites) {
|
|
139
|
+
const old = sites.get(intended.id);
|
|
140
|
+
try {
|
|
141
|
+
const newChild = await spawnChild(accessToken, intended.id, clientId, clientSecret);
|
|
142
|
+
sites.set(intended.id, {
|
|
143
|
+
cloudId: intended.id,
|
|
144
|
+
siteName: intended.name,
|
|
145
|
+
siteUrl: intended.url,
|
|
146
|
+
ownerAccountIdentifier: accountIdentifier,
|
|
147
|
+
client: newChild
|
|
148
|
+
});
|
|
149
|
+
nextLive.add(intended.id);
|
|
150
|
+
upsertSiteSummary({
|
|
151
|
+
cloudId: intended.id,
|
|
152
|
+
siteName: intended.name,
|
|
153
|
+
siteUrl: intended.url,
|
|
154
|
+
ownerAccountIdentifier: accountIdentifier,
|
|
155
|
+
ownerDisplayName: conn.displayName,
|
|
156
|
+
connectedAt: conn.connectedAt,
|
|
157
|
+
connected: true
|
|
158
|
+
});
|
|
159
|
+
if (old) try {
|
|
160
|
+
await old.client.close();
|
|
161
|
+
} catch {}
|
|
162
|
+
} 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`);
|
|
165
|
+
if (old) {
|
|
166
|
+
sites.delete(intended.id);
|
|
167
|
+
try {
|
|
168
|
+
await old.client.close();
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
upsertSiteSummary({
|
|
172
|
+
cloudId: intended.id,
|
|
173
|
+
siteName: intended.name,
|
|
174
|
+
siteUrl: intended.url,
|
|
175
|
+
ownerAccountIdentifier: accountIdentifier,
|
|
176
|
+
ownerDisplayName: conn.displayName,
|
|
177
|
+
connectedAt: conn.connectedAt,
|
|
178
|
+
connected: false,
|
|
179
|
+
reason: `respawn_failed_after_refresh: ${message}`
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
conn.accessToken = accessToken;
|
|
184
|
+
conn.liveCloudIds = nextLive;
|
|
185
|
+
return accessToken;
|
|
186
|
+
} finally {
|
|
187
|
+
inFlightRefreshByConnection.delete(accountIdentifier);
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
inFlightRefreshByConnection.set(accountIdentifier, promise);
|
|
191
|
+
return promise;
|
|
192
|
+
}
|
|
193
|
+
function scheduleRefresh(apiClient, clientId, clientSecret) {
|
|
194
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
195
|
+
refreshTimer = setTimeout(() => {
|
|
196
|
+
(async () => {
|
|
197
|
+
const ids = Array.from(connections.keys());
|
|
198
|
+
if (ids.length === 0) {
|
|
199
|
+
refreshTimer = setTimeout(() => {
|
|
200
|
+
scheduleRefresh(apiClient, clientId, clientSecret);
|
|
201
|
+
}, REFRESH_INTERVAL_MS);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
log(`Refreshing Atlassian access tokens across ${String(ids.length)} Connection(s)...`);
|
|
205
|
+
let anyFailed = false;
|
|
206
|
+
for (const id of ids) try {
|
|
207
|
+
await refreshConnection(apiClient, id, clientId, clientSecret);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
anyFailed = true;
|
|
210
|
+
log(`Token refresh failed for Connection ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
211
|
+
}
|
|
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
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async function main() {
|
|
247
|
+
const config = resolveConfig();
|
|
248
|
+
const apiClient = new AgentApiClient({
|
|
249
|
+
apiKey: config.apiKey,
|
|
250
|
+
apiUrl: config.apiUrl
|
|
251
|
+
});
|
|
252
|
+
const { accounts } = await apiClient.getAtlassianAccounts();
|
|
253
|
+
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
|
+
}
|
|
261
|
+
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
|
+
});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
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
|
+
});
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
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,
|
|
304
|
+
siteUrl: "",
|
|
305
|
+
ownerAccountIdentifier: acct.accountIdentifier,
|
|
306
|
+
ownerDisplayName: acct.displayName,
|
|
307
|
+
connectedAt: acct.connectedAt,
|
|
308
|
+
connected: false,
|
|
309
|
+
reason: "no_accessible_sites"
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
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
|
+
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({
|
|
325
|
+
cloudId: site.id,
|
|
326
|
+
siteName: site.name,
|
|
327
|
+
siteUrl: site.url,
|
|
328
|
+
ownerAccountIdentifier: acct.accountIdentifier,
|
|
329
|
+
ownerDisplayName: acct.displayName,
|
|
330
|
+
connectedAt: acct.connectedAt,
|
|
331
|
+
connected: false,
|
|
332
|
+
reason: "duplicate_cloudId"
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const child = await spawnChild(acct.accessToken, site.id, acct.clientId || sharedClientId, acct.clientSecret || sharedClientSecret);
|
|
338
|
+
sites.set(site.id, {
|
|
339
|
+
cloudId: site.id,
|
|
340
|
+
siteName: site.name,
|
|
341
|
+
siteUrl: site.url,
|
|
342
|
+
ownerAccountIdentifier: acct.accountIdentifier,
|
|
343
|
+
client: child
|
|
344
|
+
});
|
|
345
|
+
connectionEntry.liveCloudIds.add(site.id);
|
|
346
|
+
upsertSiteSummary({
|
|
347
|
+
cloudId: site.id,
|
|
348
|
+
siteName: site.name,
|
|
349
|
+
siteUrl: site.url,
|
|
350
|
+
ownerAccountIdentifier: acct.accountIdentifier,
|
|
351
|
+
ownerDisplayName: acct.displayName,
|
|
352
|
+
connectedAt: acct.connectedAt,
|
|
353
|
+
connected: true
|
|
354
|
+
});
|
|
355
|
+
log(`Spawned child for site ${site.id} (${site.name}) under Connection ${acct.accountIdentifier}`);
|
|
356
|
+
} 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({
|
|
360
|
+
cloudId: site.id,
|
|
361
|
+
siteName: site.name,
|
|
362
|
+
siteUrl: site.url,
|
|
363
|
+
ownerAccountIdentifier: acct.accountIdentifier,
|
|
364
|
+
ownerDisplayName: acct.displayName,
|
|
365
|
+
connectedAt: acct.connectedAt,
|
|
366
|
+
connected: false,
|
|
367
|
+
reason: `spawn_failed: ${message}`
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
connections.set(acct.accountIdentifier, connectionEntry);
|
|
372
|
+
}
|
|
373
|
+
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 {
|
|
379
|
+
cachedTools = [];
|
|
380
|
+
log("No child server available — only atlassian_list_accounts, atlassian_check_connection, and atlassian_refresh_token will be exposed");
|
|
381
|
+
}
|
|
382
|
+
cachedTools.push({
|
|
383
|
+
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.",
|
|
385
|
+
inputSchema: {
|
|
386
|
+
type: "object",
|
|
387
|
+
properties: {}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
cachedTools.push({
|
|
391
|
+
name: "atlassian_check_connection",
|
|
392
|
+
description: "Verify the Atlassian OAuth connection for a specific cloudId is still valid. Use this if Atlassian API calls are failing with authentication errors. The check probes /me with the access token bound to the cloudId's owning Connection.",
|
|
393
|
+
inputSchema: {
|
|
394
|
+
type: "object",
|
|
395
|
+
properties: { cloudId: {
|
|
396
|
+
type: "string",
|
|
397
|
+
description: "Atlassian Cloud site ID — use the value from atlassian_list_accounts."
|
|
398
|
+
} },
|
|
399
|
+
required: ["cloudId"]
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
cachedTools.push({
|
|
403
|
+
name: "atlassian_refresh_token",
|
|
404
|
+
description: "Force-refresh the Atlassian OAuth access token for the Connection that owns a given cloudId. One refresh rotates the access token for every site that Connection can reach. Use this if API calls are failing with 401.",
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: "object",
|
|
407
|
+
properties: { cloudId: {
|
|
408
|
+
type: "string",
|
|
409
|
+
description: "Atlassian Cloud site ID — used to look up the owning Connection. Use the value from atlassian_list_accounts."
|
|
410
|
+
} },
|
|
411
|
+
required: ["cloudId"]
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
assertPatternA(cachedTools.map((t) => ({
|
|
415
|
+
name: t.name,
|
|
416
|
+
parameters: t.inputSchema
|
|
417
|
+
})), {
|
|
418
|
+
selector: "cloudId",
|
|
419
|
+
exempt: ["atlassian_list_accounts"]
|
|
420
|
+
});
|
|
421
|
+
const proxy = new Server({
|
|
422
|
+
name: "atlassian-mcp-proxy",
|
|
423
|
+
version: "0.1.0"
|
|
424
|
+
}, { capabilities: { tools: {} } });
|
|
425
|
+
proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
|
|
426
|
+
proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
427
|
+
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 {
|
|
437
|
+
content: [{
|
|
438
|
+
type: "text",
|
|
439
|
+
text: JSON.stringify({
|
|
440
|
+
cloudId: site.cloudId,
|
|
441
|
+
connected: false,
|
|
442
|
+
error: "Owning Connection not found in proxy bookkeeping"
|
|
443
|
+
})
|
|
444
|
+
}],
|
|
445
|
+
isError: true
|
|
446
|
+
};
|
|
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 {
|
|
452
|
+
content: [{
|
|
453
|
+
type: "text",
|
|
454
|
+
text: JSON.stringify({
|
|
455
|
+
cloudId: site.cloudId,
|
|
456
|
+
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."
|
|
459
|
+
})
|
|
460
|
+
}],
|
|
461
|
+
isError: true
|
|
462
|
+
};
|
|
463
|
+
const user = await response.json();
|
|
464
|
+
return { content: [{
|
|
465
|
+
type: "text",
|
|
466
|
+
text: JSON.stringify({
|
|
467
|
+
cloudId: site.cloudId,
|
|
468
|
+
siteName: site.siteName,
|
|
469
|
+
siteUrl: site.siteUrl,
|
|
470
|
+
ownerAccountIdentifier: site.ownerAccountIdentifier,
|
|
471
|
+
connected: true,
|
|
472
|
+
user
|
|
473
|
+
})
|
|
474
|
+
}] };
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return {
|
|
477
|
+
content: [{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
480
|
+
}],
|
|
481
|
+
isError: true
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
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);
|
|
487
|
+
return { content: [{
|
|
488
|
+
type: "text",
|
|
489
|
+
text: JSON.stringify({
|
|
490
|
+
cloudId: site.cloudId,
|
|
491
|
+
ownerAccountIdentifier: site.ownerAccountIdentifier,
|
|
492
|
+
refreshed: true
|
|
493
|
+
})
|
|
494
|
+
}] };
|
|
495
|
+
} catch (err) {
|
|
496
|
+
return {
|
|
497
|
+
content: [{
|
|
498
|
+
type: "text",
|
|
499
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
500
|
+
}],
|
|
501
|
+
isError: true
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
let site;
|
|
505
|
+
try {
|
|
506
|
+
site = resolveSite(typeof argMap.cloudId === "string" ? argMap.cloudId : void 0);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
return {
|
|
509
|
+
content: [{
|
|
510
|
+
type: "text",
|
|
511
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
512
|
+
}],
|
|
513
|
+
isError: true
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const { cloudId: _ignored, ...forwarded } = argMap;
|
|
517
|
+
let result;
|
|
518
|
+
try {
|
|
519
|
+
result = await site.client.callTool({
|
|
520
|
+
name,
|
|
521
|
+
arguments: forwarded
|
|
522
|
+
});
|
|
523
|
+
} catch (err) {
|
|
524
|
+
return {
|
|
525
|
+
content: [{
|
|
526
|
+
type: "text",
|
|
527
|
+
text: JSON.stringify({
|
|
528
|
+
cloudId: site.cloudId,
|
|
529
|
+
tool: name,
|
|
530
|
+
error: err instanceof Error ? err.message : String(err)
|
|
531
|
+
})
|
|
532
|
+
}],
|
|
533
|
+
isError: true
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (result.isError) {
|
|
537
|
+
const errorText = result.content.map((c) => c.text ?? "").join("\n");
|
|
538
|
+
if (errorText.includes("401") || errorText.toLowerCase().includes("unauthorized")) {
|
|
539
|
+
log(`401 from cloudId ${site.cloudId} (owner ${site.ownerAccountIdentifier}), refreshing and retrying...`);
|
|
540
|
+
try {
|
|
541
|
+
await refreshConnection(apiClient, site.ownerAccountIdentifier, sharedClientId, sharedClientSecret);
|
|
542
|
+
const refreshed = resolveSite(site.cloudId);
|
|
543
|
+
try {
|
|
544
|
+
return await refreshed.client.callTool({
|
|
545
|
+
name,
|
|
546
|
+
arguments: forwarded
|
|
547
|
+
});
|
|
548
|
+
} catch (retryErr) {
|
|
549
|
+
return {
|
|
550
|
+
content: [{
|
|
551
|
+
type: "text",
|
|
552
|
+
text: JSON.stringify({
|
|
553
|
+
cloudId: site.cloudId,
|
|
554
|
+
tool: name,
|
|
555
|
+
error: `Retry after refresh failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`
|
|
556
|
+
})
|
|
557
|
+
}],
|
|
558
|
+
isError: true
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
} catch (refreshErr) {
|
|
562
|
+
return {
|
|
563
|
+
content: [{
|
|
564
|
+
type: "text",
|
|
565
|
+
text: JSON.stringify({
|
|
566
|
+
cloudId: site.cloudId,
|
|
567
|
+
ownerAccountIdentifier: site.ownerAccountIdentifier,
|
|
568
|
+
error: `Token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`
|
|
569
|
+
})
|
|
570
|
+
}],
|
|
571
|
+
isError: true
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return result;
|
|
577
|
+
});
|
|
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.");
|
|
580
|
+
const transport = new StdioServerTransport();
|
|
581
|
+
await proxy.connect(transport);
|
|
582
|
+
log(`Proxy running with ${String(sites.size)} site(s) across ${String(connections.size)} Connection(s) and Pattern A selector enforcement`);
|
|
583
|
+
}
|
|
584
|
+
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
|
|
585
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
586
|
+
killAllChildren().then(() => {
|
|
587
|
+
process.exit(0);
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
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
|
+
});
|
|
596
|
+
});
|
|
597
|
+
//#endregion
|
|
598
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfe.ai/atlassian-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Atlassian MCP proxy server — bridges sooperset/mcp-atlassian with Alfe OAuth credentials (Pattern A multi-site)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"atlassian-mcp-proxy": "./dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/server.d.ts",
|
|
13
|
+
"import": "./dist/server.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": ">=1.24.0",
|
|
21
|
+
"@alfe.ai/config": "0.1.0",
|
|
22
|
+
"@alfe.ai/agent-api-client": "0.3.0",
|
|
23
|
+
"@alfe.ai/mcp-bundler": "0.2.1"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"dev": "tsdown --watch",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"lint": "eslint ."
|
|
31
|
+
}
|
|
32
|
+
}
|