@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.
- package/README.md +7 -0
- package/dist/server.js +670 -215
- 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
|
|
79
|
-
* cloudId
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
481
|
+
const validatedCloudId = validateCloudId(cloudId);
|
|
482
|
+
const site = sites.get(validatedCloudId);
|
|
90
483
|
if (!site) {
|
|
91
|
-
const known = allSitesSnapshot.find((s) => s.cloudId ===
|
|
92
|
-
if (known && !known.connected) throw new Error(`cloudId ${
|
|
93
|
-
throw new Error(`Unknown cloudId: ${
|
|
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
|
-
|
|
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:
|
|
507
|
+
version: packageMetadata.version
|
|
114
508
|
});
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
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
|
|
164
|
-
log(`Token refresh succeeded for Connection ${accountIdentifier} but re-spawn failed
|
|
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,
|
|
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
|
-
|
|
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
|
|
632
|
+
await refreshConnection(apiClient, id);
|
|
208
633
|
} catch (err) {
|
|
209
634
|
anyFailed = true;
|
|
210
|
-
|
|
635
|
+
const connection = connections.get(id);
|
|
636
|
+
log(`Token refresh failed for Connection ${connectionLogId(id)}: ${safeErrorMessage(err, connection ? [connection.accessToken] : [])}`);
|
|
211
637
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
286
|
-
log(`Duplicate
|
|
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 ${
|
|
301
|
-
upsertSiteSummary({
|
|
302
|
-
cloudId:
|
|
303
|
-
siteName:
|
|
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
|
-
|
|
306
|
-
|
|
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 (
|
|
323
|
-
log(`Duplicate cloudId ${
|
|
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
|
-
|
|
329
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
connectedAt: acct.connectedAt,
|
|
749
|
+
ownerDisplayName: displayName,
|
|
750
|
+
connectedAt,
|
|
353
751
|
connected: true
|
|
354
752
|
});
|
|
355
|
-
log(`Spawned child for
|
|
753
|
+
log(`Spawned Atlassian child for Connection ${connectionLogId(accountIdentifier)}`);
|
|
356
754
|
} catch (err) {
|
|
357
|
-
const message = err
|
|
358
|
-
log(`Failed to spawn child for
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
876
|
+
cloudId,
|
|
456
877
|
connected: false,
|
|
457
|
-
status:
|
|
458
|
-
error: "Token may have been revoked
|
|
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
|
|
468
|
-
siteName: site
|
|
469
|
-
siteUrl: site
|
|
470
|
-
|
|
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:
|
|
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
|
-
|
|
486
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
966
|
+
const errorText = extractToolErrorText(result);
|
|
538
967
|
if (errorText.includes("401") || errorText.toLowerCase().includes("unauthorized")) {
|
|
539
|
-
log(`401
|
|
968
|
+
log(`Atlassian child returned 401 for Connection ${connectionLogId(site.ownerAccountIdentifier)}; refreshing once`);
|
|
540
969
|
try {
|
|
541
|
-
await refreshConnection(apiClient, site.ownerAccountIdentifier
|
|
970
|
+
await refreshConnection(apiClient, site.ownerAccountIdentifier);
|
|
542
971
|
const refreshed = resolveSite(site.cloudId);
|
|
543
972
|
try {
|
|
544
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1037
|
+
let shutdownPromise = null;
|
|
1038
|
+
function shutdown(exitCode) {
|
|
1039
|
+
if (shutdownPromise !== null) return;
|
|
585
1040
|
if (refreshTimer) clearTimeout(refreshTimer);
|
|
586
|
-
killAllChildren().
|
|
587
|
-
process.exit(
|
|
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
|
|
592
|
-
|
|
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.
|
|
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.
|
|
22
|
-
"@alfe.ai/agent-api-client": "0.
|
|
23
|
-
"@alfe.ai/mcp-bundler": "0.4.
|
|
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
|
}
|