@atlassian-dc-mcp/common 0.27.0 → 0.28.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/CHANGELOG.md +12 -0
- package/build/__tests__/attachment-download.test.d.ts +2 -0
- package/build/__tests__/attachment-download.test.d.ts.map +1 -0
- package/build/__tests__/attachment-download.test.js +119 -0
- package/build/__tests__/attachment-download.test.js.map +1 -0
- package/build/__tests__/attachment-gateway.test.d.ts +2 -0
- package/build/__tests__/attachment-gateway.test.d.ts.map +1 -0
- package/build/__tests__/attachment-gateway.test.js +123 -0
- package/build/__tests__/attachment-gateway.test.js.map +1 -0
- package/build/attachment-download.d.ts +54 -0
- package/build/attachment-download.d.ts.map +1 -0
- package/build/attachment-download.js +94 -0
- package/build/attachment-download.js.map +1 -0
- package/build/attachment-gateway.d.ts +57 -0
- package/build/attachment-gateway.d.ts.map +1 -0
- package/build/attachment-gateway.js +201 -0
- package/build/attachment-gateway.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/attachment-download.test.ts +162 -0
- package/src/__tests__/attachment-gateway.test.ts +156 -0
- package/src/attachment-download.ts +154 -0
- package/src/attachment-gateway.ts +255 -0
- package/src/index.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { basename, delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Operator-controlled gateway that lets the attachment tools read from / write to
|
|
6
|
+
* the local filesystem. It is disabled by default: filesystem access must be
|
|
7
|
+
* explicitly enabled and confined to canonical root directories that the model
|
|
8
|
+
* cannot choose. Configured entirely through environment variables named after
|
|
9
|
+
* the product (e.g. JIRA_ATTACHMENTS_*).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MiB
|
|
12
|
+
function envPrefix(product) {
|
|
13
|
+
return product.id.toUpperCase();
|
|
14
|
+
}
|
|
15
|
+
function readBool(env, name) {
|
|
16
|
+
const value = env[name]?.trim().toLowerCase();
|
|
17
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
18
|
+
}
|
|
19
|
+
function readBytes(env, name) {
|
|
20
|
+
const raw = env[name]?.trim();
|
|
21
|
+
if (!raw || !/^\d+$/.test(raw)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const parsed = Number.parseInt(raw, 10);
|
|
25
|
+
return parsed > 0 ? parsed : undefined;
|
|
26
|
+
}
|
|
27
|
+
function readRoots(env, names) {
|
|
28
|
+
const roots = [];
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const raw = env[name];
|
|
31
|
+
if (!raw) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
for (const part of raw.split(delimiter)) {
|
|
35
|
+
const trimmed = part.trim();
|
|
36
|
+
if (trimmed) {
|
|
37
|
+
roots.push(trimmed);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return roots;
|
|
42
|
+
}
|
|
43
|
+
function canonicalizeRoots(rawRoots, warn) {
|
|
44
|
+
const canonical = [];
|
|
45
|
+
for (const root of rawRoots) {
|
|
46
|
+
if (!isAbsolute(root)) {
|
|
47
|
+
warn(`Ignoring attachment root that is not an absolute path: "${root}"`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const real = realpathSync(root);
|
|
52
|
+
if (!canonical.includes(real)) {
|
|
53
|
+
canonical.push(real);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
warn(`Ignoring attachment root that does not exist or cannot be resolved: "${root}"`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return canonical;
|
|
61
|
+
}
|
|
62
|
+
function resolveSide(args) {
|
|
63
|
+
if (!args.enabledFlag) {
|
|
64
|
+
return { enabled: false, roots: [], maxBytes: args.maxBytes };
|
|
65
|
+
}
|
|
66
|
+
const roots = canonicalizeRoots(args.rawRoots, args.warn);
|
|
67
|
+
if (roots.length === 0) {
|
|
68
|
+
args.warn(`${args.label} attachments were enabled but no valid directory is configured ` +
|
|
69
|
+
`(set ${args.rootEnvHint}); the ${args.label} tool will stay disabled.`);
|
|
70
|
+
return { enabled: false, roots: [], maxBytes: args.maxBytes };
|
|
71
|
+
}
|
|
72
|
+
return { enabled: true, roots, maxBytes: args.maxBytes };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reads the attachment gateway configuration for a product from the environment.
|
|
76
|
+
* Roots are canonicalized once here; a direction is only reported as enabled when
|
|
77
|
+
* its flag is set and at least one valid root resolves.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveAttachmentGateway(product, options) {
|
|
80
|
+
const env = options?.env ?? process.env;
|
|
81
|
+
const warn = options?.warn ?? ((message) => console.error(`[attachment-gateway] ${message}`));
|
|
82
|
+
const p = envPrefix(product);
|
|
83
|
+
const exchangeDir = env[`${p}_ATTACHMENTS_DIR`]?.trim();
|
|
84
|
+
const dirRoots = exchangeDir ? [exchangeDir] : [];
|
|
85
|
+
const upload = resolveSide({
|
|
86
|
+
enabledFlag: readBool(env, `${p}_ATTACHMENTS_UPLOAD_ENABLED`),
|
|
87
|
+
rawRoots: [...readRoots(env, [`${p}_ATTACHMENTS_UPLOAD_ROOTS`]), ...dirRoots],
|
|
88
|
+
maxBytes: readBytes(env, `${p}_ATTACHMENTS_MAX_UPLOAD_BYTES`) ?? DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
89
|
+
label: 'upload',
|
|
90
|
+
rootEnvHint: `${p}_ATTACHMENTS_UPLOAD_ROOTS or ${p}_ATTACHMENTS_DIR`,
|
|
91
|
+
warn,
|
|
92
|
+
});
|
|
93
|
+
const download = resolveSide({
|
|
94
|
+
enabledFlag: readBool(env, `${p}_ATTACHMENTS_DOWNLOAD_ENABLED`),
|
|
95
|
+
rawRoots: [...readRoots(env, [`${p}_ATTACHMENTS_DOWNLOAD_ROOTS`]), ...dirRoots],
|
|
96
|
+
maxBytes: readBytes(env, `${p}_ATTACHMENTS_MAX_DOWNLOAD_BYTES`) ?? DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
97
|
+
label: 'download',
|
|
98
|
+
rootEnvHint: `${p}_ATTACHMENTS_DOWNLOAD_ROOTS or ${p}_ATTACHMENTS_DIR`,
|
|
99
|
+
warn,
|
|
100
|
+
});
|
|
101
|
+
return { upload, download };
|
|
102
|
+
}
|
|
103
|
+
function normalizeRelative(requested) {
|
|
104
|
+
if (!requested || !requested.trim()) {
|
|
105
|
+
throw new Error('A file path is required');
|
|
106
|
+
}
|
|
107
|
+
if (isAbsolute(requested)) {
|
|
108
|
+
throw new Error(`Path must be relative to a configured attachment root, not absolute: "${requested}"`);
|
|
109
|
+
}
|
|
110
|
+
const norm = normalize(requested);
|
|
111
|
+
if (norm === '.' || norm === '..' || norm === '' || norm.startsWith(`..${sep}`) || norm.startsWith('../')) {
|
|
112
|
+
throw new Error(`Path must stay within the configured attachment root: "${requested}"`);
|
|
113
|
+
}
|
|
114
|
+
return norm;
|
|
115
|
+
}
|
|
116
|
+
function withinRoot(candidate, root) {
|
|
117
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Canonicalizes the deepest existing ancestor of `target` (resolving symlinked
|
|
121
|
+
* directories) and re-attaches the not-yet-existing tail. Used to confirm a path
|
|
122
|
+
* cannot escape a root through a symlinked parent directory.
|
|
123
|
+
*/
|
|
124
|
+
async function canonicalizeDeepestExisting(target) {
|
|
125
|
+
let current = target;
|
|
126
|
+
while (true) {
|
|
127
|
+
try {
|
|
128
|
+
const real = await realpath(current);
|
|
129
|
+
const tail = relative(current, target);
|
|
130
|
+
return tail ? join(real, tail) : real;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
const parent = dirname(current);
|
|
134
|
+
if (parent === current) {
|
|
135
|
+
throw new Error(`Cannot resolve path: "${target}"`);
|
|
136
|
+
}
|
|
137
|
+
current = parent;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Resolves `requested` (relative) against the allowed roots, returning an absolute
|
|
143
|
+
* path whose parent directory is canonically inside a root. The final path
|
|
144
|
+
* component is NOT symlink-resolved, so callers can still detect a symlinked leaf.
|
|
145
|
+
*/
|
|
146
|
+
async function resolveLeafWithinRoots(requested, roots) {
|
|
147
|
+
const norm = normalizeRelative(requested);
|
|
148
|
+
for (const root of roots) {
|
|
149
|
+
const lexical = resolve(root, norm);
|
|
150
|
+
if (!withinRoot(lexical, root)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const parentReal = await canonicalizeDeepestExisting(dirname(lexical));
|
|
154
|
+
if (withinRoot(parentReal, root)) {
|
|
155
|
+
return join(parentReal, basename(lexical));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`Path resolves outside the configured attachment root(s): "${requested}"`);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolves and validates a file to upload: it must live inside an allowed upload
|
|
162
|
+
* root, be a regular file (never a symlink or special file), and be within the
|
|
163
|
+
* size limit. Returns the absolute path to read.
|
|
164
|
+
*/
|
|
165
|
+
export async function resolveUploadSource(params) {
|
|
166
|
+
if (!params.side.enabled) {
|
|
167
|
+
throw new Error('Attachment upload from the local filesystem is disabled on this server');
|
|
168
|
+
}
|
|
169
|
+
const absolutePath = await resolveLeafWithinRoots(params.requestedPath, params.side.roots);
|
|
170
|
+
const info = await lstat(absolutePath);
|
|
171
|
+
if (info.isSymbolicLink()) {
|
|
172
|
+
throw new Error(`Refusing to upload a symlink: "${params.requestedPath}"`);
|
|
173
|
+
}
|
|
174
|
+
if (!info.isFile()) {
|
|
175
|
+
throw new Error(`Refusing to upload a non-regular file: "${params.requestedPath}"`);
|
|
176
|
+
}
|
|
177
|
+
if (info.size > params.side.maxBytes) {
|
|
178
|
+
throw new Error(`File size ${info.size} bytes exceeds the configured upload limit of ${params.side.maxBytes} bytes`);
|
|
179
|
+
}
|
|
180
|
+
return { absolutePath, size: info.size };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Resolves a destination path to save a downloaded attachment into. The file is
|
|
184
|
+
* placed in the first configured download root; the caller writes with an
|
|
185
|
+
* exclusive flag so existing files (including symlinks) are never overwritten.
|
|
186
|
+
*/
|
|
187
|
+
export async function resolveDownloadDestination(params) {
|
|
188
|
+
if (!params.side.enabled) {
|
|
189
|
+
throw new Error('Saving attachments to the local filesystem is disabled on this server');
|
|
190
|
+
}
|
|
191
|
+
const safeName = basename(params.requestedName);
|
|
192
|
+
if (!safeName || safeName === '.' || safeName === '..') {
|
|
193
|
+
throw new Error(`Invalid attachment file name: "${params.requestedName}"`);
|
|
194
|
+
}
|
|
195
|
+
return resolveLeafWithinRoots(safeName, [params.side.roots[0]]);
|
|
196
|
+
}
|
|
197
|
+
/** Resolves a path within roots, throwing if it escapes. Exported for verification/tests. */
|
|
198
|
+
export async function assertWithinRoots(requested, roots) {
|
|
199
|
+
return resolveLeafWithinRoots(requested, roots);
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=attachment-gateway.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachment-gateway.js","sourceRoot":"","sources":["../src/attachment-gateway.ts"],"names":[],"mappings":"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"}
|
package/build/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
export * from './api-error-handler.js';
|
|
3
|
+
export * from './attachment-download.js';
|
|
4
|
+
export * from './attachment-gateway.js';
|
|
3
5
|
export * from './config/index.js';
|
|
4
6
|
export { runSetup, runSetupCli } from './setup-cli.js';
|
|
5
7
|
export { describeValidationError } from './setup/describe-error.js';
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,cAAc,wBAAwB,CAAA;AACtC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAGvG,eAAO,MAAM,kBAAkB,GAAI,QAAQ,OAAO;;;;;CAKhD,CAAC;AAGH,eAAO,MAAM,WAAW,GAAI,OAAO,KAAK,UAGvC,CAAC;AAGF,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,aAKA;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,SAAS,sBAkCpD"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,cAAc,wBAAwB,CAAA;AACtC,cAAc,0BAA0B,CAAA;AACxC,cAAc,yBAAyB,CAAA;AACvC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAGvG,eAAO,MAAM,kBAAkB,GAAI,QAAQ,OAAO;;;;;CAKhD,CAAC;AAGH,eAAO,MAAM,WAAW,GAAI,OAAO,KAAK,UAGvC,CAAC;AAGF,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,aAKA;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,SAAS,sBAkCpD"}
|
package/build/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
export * from './api-error-handler.js';
|
|
4
|
+
export * from './attachment-download.js';
|
|
5
|
+
export * from './attachment-gateway.js';
|
|
4
6
|
export * from './config/index.js';
|
|
5
7
|
export { runSetup, runSetupCli } from './setup-cli.js';
|
|
6
8
|
export { describeValidationError } from './setup/describe-error.js';
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,cAAc,wBAAwB,CAAA;AACtC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,cAAc,EAAwB,MAAM,iBAAiB,CAAC;AAEvG,2CAA2C;AAC3C,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,MAAe,EAAE,EAAE,CAAC,CAAC;IACtD,OAAO,EAAE,CAAC;YACR,IAAI,EAAE,MAAe;YACrB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;SAC7B,CAAC;CACH,CAAC,CAAC;AAEH,uBAAuB;AACvB,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAY,EAAE,EAAE;IAC1C,OAAO,CAAC,KAAK,CAAC,eAAe,EAAE,KAAK,CAAC,CAAC;IACtC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC;AAEF,sCAAsC;AACtC,MAAM,UAAU,eAAe,CAAC,OAG/B;IACC,OAAO,IAAI,SAAS,CAAC;QACnB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,OAAO,EAAE,OAAO,CAAC,OAAO;KACzB,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAiB;IACnD,0DAA0D;IAC1D,yBAAyB;IACzB,iDAAiD;IACjD,EAAE;IACF,wCAAwC;IACxC,0DAA0D;IAC1D,qCAAqC;IACrC,MAAM;IACN,EAAE;IACF,8CAA8C;IAC9C,kDAAkD;IAClD,MAAM;IACN,EAAE;IACF,mDAAmD;IACnD,sCAAsC;IACtC,2CAA2C;IAC3C,kDAAkD;IAClD,2CAA2C;IAC3C,4CAA4C;IAC5C,mBAAmB;IACnB,uBAAuB;IACvB,gBAAgB;IAChB,QAAQ;IACR,EAAE;IACF,uDAAuD;IACvD,qBAAqB;IACrB,QAAQ;IACR,MAAM;IAGN,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,cAAc,wBAAwB,CAAA;AACtC,cAAc,0BAA0B,CAAA;AACxC,cAAc,yBAAyB,CAAA;AACvC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,cAAc,EAAwB,MAAM,iBAAiB,CAAC;AAEvG,2CAA2C;AAC3C,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,MAAe,EAAE,EAAE,CAAC,CAAC;IACtD,OAAO,EAAE,CAAC;YACR,IAAI,EAAE,MAAe;YACrB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;SAC7B,CAAC;CACH,CAAC,CAAC;AAEH,uBAAuB;AACvB,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAY,EAAE,EAAE;IAC1C,OAAO,CAAC,KAAK,CAAC,eAAe,EAAE,KAAK,CAAC,CAAC;IACtC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC;AAEF,sCAAsC;AACtC,MAAM,UAAU,eAAe,CAAC,OAG/B;IACC,OAAO,IAAI,SAAS,CAAC;QACnB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,OAAO,EAAE,OAAO,CAAC,OAAO;KACzB,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAiB;IACnD,0DAA0D;IAC1D,yBAAyB;IACzB,iDAAiD;IACjD,EAAE;IACF,wCAAwC;IACxC,0DAA0D;IAC1D,qCAAqC;IACrC,MAAM;IACN,EAAE;IACF,8CAA8C;IAC9C,kDAAkD;IAClD,MAAM;IACN,EAAE;IACF,mDAAmD;IACnD,sCAAsC;IACtC,2CAA2C;IAC3C,kDAAkD;IAClD,2CAA2C;IAC3C,4CAA4C;IAC5C,mBAAmB;IACnB,uBAAuB;IACvB,gBAAgB;IAChB,QAAQ;IACR,EAAE;IACF,uDAAuD;IACvD,qBAAqB;IACrB,QAAQ;IACR,MAAM;IAGN,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlassian-dc-mcp/common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -39,5 +39,5 @@
|
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
|
-
"gitHead": "
|
|
42
|
+
"gitHead": "7f51cd1b0328ecc90785211511415e9609d09a25"
|
|
43
43
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { downloadAttachment } from '../attachment-download.js';
|
|
5
|
+
|
|
6
|
+
function mockFetchOnce(
|
|
7
|
+
body: Buffer,
|
|
8
|
+
init?: { ok?: boolean; status?: number; statusText?: string; contentType?: string; contentLength?: string; stream?: boolean },
|
|
9
|
+
) {
|
|
10
|
+
const ok = init?.ok ?? true;
|
|
11
|
+
const headers = new Map<string, string>();
|
|
12
|
+
if (init?.contentType) headers.set('content-type', init.contentType);
|
|
13
|
+
if (init?.contentLength) headers.set('content-length', init.contentLength);
|
|
14
|
+
|
|
15
|
+
const response: Record<string, unknown> = {
|
|
16
|
+
ok,
|
|
17
|
+
status: init?.status ?? (ok ? 200 : 500),
|
|
18
|
+
statusText: init?.statusText ?? (ok ? 'OK' : 'Error'),
|
|
19
|
+
headers: { get: (name: string) => headers.get(name.toLowerCase()) ?? null },
|
|
20
|
+
arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (init?.stream) {
|
|
24
|
+
let sent = false;
|
|
25
|
+
response.body = {
|
|
26
|
+
getReader: () => ({
|
|
27
|
+
read: async () => (sent ? { done: true, value: undefined } : ((sent = true), { done: false, value: new Uint8Array(body) })),
|
|
28
|
+
cancel: async () => undefined,
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
global.fetch = jest.fn().mockResolvedValue(response) as unknown as typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('downloadAttachment', () => {
|
|
37
|
+
let tmpDir: string;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-dl-'));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
45
|
+
jest.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('writes the file to the provided destination and reports metadata', async () => {
|
|
49
|
+
mockFetchOnce(Buffer.from('hello world'), { contentType: 'text/plain' });
|
|
50
|
+
const destination = path.join(tmpDir, 'note.txt');
|
|
51
|
+
|
|
52
|
+
const result = await downloadAttachment({
|
|
53
|
+
url: 'https://host/download/x',
|
|
54
|
+
token: 'tok',
|
|
55
|
+
filename: 'note.txt',
|
|
56
|
+
options: { destination },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(global.fetch).toHaveBeenCalledWith('https://host/download/x', {
|
|
60
|
+
headers: { Authorization: 'Bearer tok', 'X-Atlassian-Token': 'nocheck' },
|
|
61
|
+
});
|
|
62
|
+
expect(result.savedPath).toBe(destination);
|
|
63
|
+
expect(result.size).toBe(11);
|
|
64
|
+
expect(fs.readFileSync(destination, 'utf-8')).toBe('hello world');
|
|
65
|
+
expect(result.content).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('refuses to overwrite an existing destination file', async () => {
|
|
69
|
+
mockFetchOnce(Buffer.from('new content'));
|
|
70
|
+
const destination = path.join(tmpDir, 'exists.txt');
|
|
71
|
+
fs.writeFileSync(destination, 'original');
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
downloadAttachment({ url: 'https://host/x', token: 'tok', filename: 'exists.txt', options: { destination } }),
|
|
75
|
+
).rejects.toThrow();
|
|
76
|
+
expect(fs.readFileSync(destination, 'utf-8')).toBe('original');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns text content inline when requested', async () => {
|
|
80
|
+
mockFetchOnce(Buffer.from('inline text'));
|
|
81
|
+
|
|
82
|
+
const result = await downloadAttachment({
|
|
83
|
+
url: 'https://host/download/x',
|
|
84
|
+
token: () => 'tok',
|
|
85
|
+
filename: 'note.txt',
|
|
86
|
+
options: { returnContent: 'text' },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.content).toBe('inline text');
|
|
90
|
+
expect(result.encoding).toBe('text');
|
|
91
|
+
expect(result.savedPath).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns base64 content inline when requested', async () => {
|
|
95
|
+
const bytes = Buffer.from([0x00, 0x01, 0x02, 0xff]);
|
|
96
|
+
mockFetchOnce(bytes);
|
|
97
|
+
|
|
98
|
+
const result = await downloadAttachment({
|
|
99
|
+
url: 'https://host/download/x',
|
|
100
|
+
token: 'tok',
|
|
101
|
+
filename: 'blob.bin',
|
|
102
|
+
options: { returnContent: 'base64' },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.content).toBe(bytes.toString('base64'));
|
|
106
|
+
expect(result.encoding).toBe('base64');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('omits inline content when the file exceeds maxInlineBytes', async () => {
|
|
110
|
+
mockFetchOnce(Buffer.from('123456'));
|
|
111
|
+
|
|
112
|
+
const result = await downloadAttachment({
|
|
113
|
+
url: 'https://host/download/x',
|
|
114
|
+
token: 'tok',
|
|
115
|
+
filename: 'big.txt',
|
|
116
|
+
options: { returnContent: 'text', maxInlineBytes: 3 },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.content).toBeUndefined();
|
|
120
|
+
expect(result.contentOmittedReason).toContain('exceeds inline cap');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects early when content-length exceeds the download cap', async () => {
|
|
124
|
+
mockFetchOnce(Buffer.from('1234567890'), { contentLength: '10' });
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
downloadAttachment({ url: 'https://host/x', token: 'tok', filename: 'big.bin', options: { maxDownloadBytes: 5 } }),
|
|
128
|
+
).rejects.toThrow('exceeds the configured download limit');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('aborts a streamed body that exceeds the download cap', async () => {
|
|
132
|
+
mockFetchOnce(Buffer.from('1234567890'), { stream: true });
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
downloadAttachment({ url: 'https://host/x', token: 'tok', filename: 'big.bin', options: { maxDownloadBytes: 5 } }),
|
|
136
|
+
).rejects.toThrow('exceeds the configured download limit');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('enforces the cap on a buffered body without content-length', async () => {
|
|
140
|
+
mockFetchOnce(Buffer.from('1234567890'));
|
|
141
|
+
|
|
142
|
+
await expect(
|
|
143
|
+
downloadAttachment({ url: 'https://host/x', token: 'tok', filename: 'big.bin', options: { maxDownloadBytes: 5 } }),
|
|
144
|
+
).rejects.toThrow('exceeds the configured download limit');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('throws when the response is not ok', async () => {
|
|
148
|
+
mockFetchOnce(Buffer.from(''), { ok: false, status: 404, statusText: 'Not Found' });
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
downloadAttachment({ url: 'https://host/x', token: 'tok', filename: 'f.txt' }),
|
|
152
|
+
).rejects.toThrow('404 Not Found');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('throws when no token is available', async () => {
|
|
156
|
+
mockFetchOnce(Buffer.from(''));
|
|
157
|
+
|
|
158
|
+
await expect(
|
|
159
|
+
downloadAttachment({ url: 'https://host/x', token: () => undefined, filename: 'f.txt' }),
|
|
160
|
+
).rejects.toThrow('Missing API token');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
6
|
+
resolveAttachmentGateway,
|
|
7
|
+
resolveDownloadDestination,
|
|
8
|
+
resolveUploadSource,
|
|
9
|
+
} from '../attachment-gateway.js';
|
|
10
|
+
import type { ProductDefinition } from '../config/source.js';
|
|
11
|
+
|
|
12
|
+
const PRODUCT: ProductDefinition = {
|
|
13
|
+
id: 'jira',
|
|
14
|
+
envVars: { host: 'JIRA_HOST', apiBasePath: 'JIRA_API_BASE_PATH', token: 'JIRA_API_TOKEN', defaultPageSize: 'JIRA_DEFAULT_PAGE_SIZE' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const silentWarn = () => undefined;
|
|
18
|
+
|
|
19
|
+
describe('resolveAttachmentGateway', () => {
|
|
20
|
+
let root: string;
|
|
21
|
+
let realRoot: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-gw-'));
|
|
25
|
+
realRoot = fs.realpathSync(root);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is disabled by default', () => {
|
|
33
|
+
const gw = resolveAttachmentGateway(PRODUCT, { env: {}, warn: silentWarn });
|
|
34
|
+
expect(gw.upload.enabled).toBe(false);
|
|
35
|
+
expect(gw.download.enabled).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('stays disabled when enabled but no valid root is configured', () => {
|
|
39
|
+
const gw = resolveAttachmentGateway(PRODUCT, {
|
|
40
|
+
env: { JIRA_ATTACHMENTS_UPLOAD_ENABLED: 'true' },
|
|
41
|
+
warn: silentWarn,
|
|
42
|
+
});
|
|
43
|
+
expect(gw.upload.enabled).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('enables upload with a canonical root and default size limit', () => {
|
|
47
|
+
const gw = resolveAttachmentGateway(PRODUCT, {
|
|
48
|
+
env: { JIRA_ATTACHMENTS_UPLOAD_ENABLED: 'true', JIRA_ATTACHMENTS_UPLOAD_ROOTS: root },
|
|
49
|
+
warn: silentWarn,
|
|
50
|
+
});
|
|
51
|
+
expect(gw.upload.enabled).toBe(true);
|
|
52
|
+
expect(gw.upload.roots).toEqual([realRoot]);
|
|
53
|
+
expect(gw.upload.maxBytes).toBe(DEFAULT_MAX_ATTACHMENT_BYTES);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('uses JIRA_ATTACHMENTS_DIR as a shared exchange root and honours size overrides', () => {
|
|
57
|
+
const gw = resolveAttachmentGateway(PRODUCT, {
|
|
58
|
+
env: {
|
|
59
|
+
JIRA_ATTACHMENTS_UPLOAD_ENABLED: 'true',
|
|
60
|
+
JIRA_ATTACHMENTS_DOWNLOAD_ENABLED: '1',
|
|
61
|
+
JIRA_ATTACHMENTS_DIR: root,
|
|
62
|
+
JIRA_ATTACHMENTS_MAX_UPLOAD_BYTES: '1024',
|
|
63
|
+
},
|
|
64
|
+
warn: silentWarn,
|
|
65
|
+
});
|
|
66
|
+
expect(gw.upload.roots).toEqual([realRoot]);
|
|
67
|
+
expect(gw.download.roots).toEqual([realRoot]);
|
|
68
|
+
expect(gw.upload.maxBytes).toBe(1024);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('resolveUploadSource', () => {
|
|
73
|
+
let root: string;
|
|
74
|
+
const side = () => ({ enabled: true, roots: [fs.realpathSync(root)], maxBytes: 100 });
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-up-'));
|
|
78
|
+
});
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves a regular file inside the root', async () => {
|
|
84
|
+
fs.writeFileSync(path.join(root, 'a.txt'), 'hi');
|
|
85
|
+
const { absolutePath, size } = await resolveUploadSource({ requestedPath: 'a.txt', side: side() });
|
|
86
|
+
expect(absolutePath).toBe(path.join(fs.realpathSync(root), 'a.txt'));
|
|
87
|
+
expect(size).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects absolute paths', async () => {
|
|
91
|
+
await expect(resolveUploadSource({ requestedPath: '/etc/passwd', side: side() })).rejects.toThrow('not absolute');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects traversal outside the root', async () => {
|
|
95
|
+
await expect(resolveUploadSource({ requestedPath: '../secret', side: side() })).rejects.toThrow('within the configured');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects a symlink leaf', async () => {
|
|
99
|
+
const target = path.join(root, 'real.txt');
|
|
100
|
+
fs.writeFileSync(target, 'secret');
|
|
101
|
+
fs.symlinkSync(target, path.join(root, 'link.txt'));
|
|
102
|
+
await expect(resolveUploadSource({ requestedPath: 'link.txt', side: side() })).rejects.toThrow('symlink');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects a path that escapes via a symlinked directory', async () => {
|
|
106
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-out-'));
|
|
107
|
+
fs.writeFileSync(path.join(outside, 'secret.txt'), 'x');
|
|
108
|
+
fs.symlinkSync(outside, path.join(root, 'escape'));
|
|
109
|
+
await expect(resolveUploadSource({ requestedPath: 'escape/secret.txt', side: side() })).rejects.toThrow('outside');
|
|
110
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('rejects a non-regular file (directory)', async () => {
|
|
114
|
+
fs.mkdirSync(path.join(root, 'sub'));
|
|
115
|
+
await expect(resolveUploadSource({ requestedPath: 'sub', side: side() })).rejects.toThrow('non-regular');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a file over the size limit', async () => {
|
|
119
|
+
fs.writeFileSync(path.join(root, 'big.txt'), 'x'.repeat(200));
|
|
120
|
+
await expect(resolveUploadSource({ requestedPath: 'big.txt', side: side() })).rejects.toThrow('exceeds the configured upload limit');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('throws when upload is disabled', async () => {
|
|
124
|
+
await expect(
|
|
125
|
+
resolveUploadSource({ requestedPath: 'a.txt', side: { enabled: false, roots: [], maxBytes: 100 } }),
|
|
126
|
+
).rejects.toThrow('disabled');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('resolveDownloadDestination', () => {
|
|
131
|
+
let root: string;
|
|
132
|
+
const side = () => ({ enabled: true, roots: [fs.realpathSync(root)], maxBytes: 100 });
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-dd-'));
|
|
136
|
+
});
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('resolves a basename into the download root', async () => {
|
|
142
|
+
const dest = await resolveDownloadDestination({ requestedName: 'out.bin', side: side() });
|
|
143
|
+
expect(dest).toBe(path.join(fs.realpathSync(root), 'out.bin'));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('strips directory components from the requested name', async () => {
|
|
147
|
+
const dest = await resolveDownloadDestination({ requestedName: '../../etc/passwd', side: side() });
|
|
148
|
+
expect(dest).toBe(path.join(fs.realpathSync(root), 'passwd'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('throws when saving is disabled', async () => {
|
|
152
|
+
await expect(
|
|
153
|
+
resolveDownloadDestination({ requestedName: 'out.bin', side: { enabled: false, roots: [], maxBytes: 100 } }),
|
|
154
|
+
).rejects.toThrow('disabled');
|
|
155
|
+
});
|
|
156
|
+
});
|