@atlassian-dc-mcp/common 0.26.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 +20 -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,154 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How the downloaded bytes should be returned to the caller inline, in addition
|
|
6
|
+
* to (or instead of) being written to disk.
|
|
7
|
+
* - `none`: do not embed the bytes in the response (default)
|
|
8
|
+
* - `base64`: embed base64-encoded bytes (suitable for binary files)
|
|
9
|
+
* - `text`: embed the bytes decoded as UTF-8 text (suitable for text files)
|
|
10
|
+
*/
|
|
11
|
+
export type AttachmentContentEncoding = 'none' | 'base64' | 'text';
|
|
12
|
+
|
|
13
|
+
/** Default cap for inline content so responses do not balloon. 1 MiB. */
|
|
14
|
+
export const DEFAULT_MAX_INLINE_BYTES = 1_048_576;
|
|
15
|
+
|
|
16
|
+
export interface AttachmentDownloadOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Absolute, already-validated destination path to write the bytes to. Resolving
|
|
19
|
+
* and sandboxing this path is the caller's responsibility (see the attachment
|
|
20
|
+
* gateway); the file is written with an exclusive flag and never overwrites an
|
|
21
|
+
* existing file. When omitted, nothing is written to disk.
|
|
22
|
+
*/
|
|
23
|
+
destination?: string;
|
|
24
|
+
/** Whether and how to embed the bytes in the response. Defaults to `none`. */
|
|
25
|
+
returnContent?: AttachmentContentEncoding;
|
|
26
|
+
/** Maximum bytes to embed inline when returnContent is not `none`. */
|
|
27
|
+
maxInlineBytes?: number;
|
|
28
|
+
/** Hard cap on the number of downloaded bytes. Exceeding it aborts the download. */
|
|
29
|
+
maxDownloadBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AttachmentDownloadResult {
|
|
33
|
+
filename: string;
|
|
34
|
+
mediaType?: string;
|
|
35
|
+
/** Number of bytes downloaded. */
|
|
36
|
+
size: number;
|
|
37
|
+
/** Absolute path the file was written to, when saving was requested. */
|
|
38
|
+
savedPath?: string;
|
|
39
|
+
/** Inline content, when returnContent is `base64` or `text`. */
|
|
40
|
+
content?: string;
|
|
41
|
+
encoding?: 'base64' | 'text';
|
|
42
|
+
/** Set when inline content was requested but omitted (e.g. over the size cap). */
|
|
43
|
+
contentOmittedReason?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function resolveToken(token: string | (() => string | undefined)): Promise<string> {
|
|
47
|
+
const resolved = typeof token === 'function' ? token() : token;
|
|
48
|
+
if (!resolved) {
|
|
49
|
+
throw new Error('Missing API token for attachment download');
|
|
50
|
+
}
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads the response body, enforcing `cap` (when set) without buffering more than
|
|
56
|
+
* the limit. Streams chunk-by-chunk when a web ReadableStream is available and
|
|
57
|
+
* falls back to a buffered read with a post-check otherwise.
|
|
58
|
+
*/
|
|
59
|
+
async function readBodyWithCap(response: Response, cap: number | undefined, filename: string): Promise<Buffer> {
|
|
60
|
+
const body = response.body as ReadableStream<Uint8Array> | null;
|
|
61
|
+
if (cap !== undefined && body && typeof body.getReader === 'function') {
|
|
62
|
+
const reader = body.getReader();
|
|
63
|
+
const chunks: Buffer[] = [];
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
total += value.byteLength;
|
|
71
|
+
if (total > cap) {
|
|
72
|
+
await reader.cancel();
|
|
73
|
+
throw new Error(`Attachment "${filename}" exceeds the configured download limit of ${cap} bytes`);
|
|
74
|
+
}
|
|
75
|
+
chunks.push(Buffer.from(value));
|
|
76
|
+
}
|
|
77
|
+
return Buffer.concat(chunks);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
81
|
+
if (cap !== undefined && buffer.length > cap) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Attachment "${filename}" size ${buffer.length} bytes exceeds the configured download limit of ${cap} bytes`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return buffer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Downloads a file from an authenticated Atlassian Data Center URL using a
|
|
91
|
+
* Bearer token, optionally writing it to a pre-validated destination and/or
|
|
92
|
+
* returning its bytes inline.
|
|
93
|
+
*
|
|
94
|
+
* Writing uses an exclusive flag, so an existing file (including a symlink) is
|
|
95
|
+
* never overwritten.
|
|
96
|
+
*/
|
|
97
|
+
export async function downloadAttachment(params: {
|
|
98
|
+
url: string;
|
|
99
|
+
token: string | (() => string | undefined);
|
|
100
|
+
filename: string;
|
|
101
|
+
mediaType?: string;
|
|
102
|
+
options?: AttachmentDownloadOptions;
|
|
103
|
+
}): Promise<AttachmentDownloadResult> {
|
|
104
|
+
const { url, filename, mediaType } = params;
|
|
105
|
+
const options = params.options ?? {};
|
|
106
|
+
const token = await resolveToken(params.token);
|
|
107
|
+
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${token}`,
|
|
111
|
+
// Attachment download endpoints are XSRF-protected; nocheck bypasses it.
|
|
112
|
+
'X-Atlassian-Token': 'nocheck',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(`Failed to download attachment "${filename}": ${response.status} ${response.statusText}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cap = options.maxDownloadBytes;
|
|
121
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
122
|
+
if (cap !== undefined && Number.isFinite(declaredLength) && declaredLength > cap) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Attachment "${filename}" size ${declaredLength} bytes exceeds the configured download limit of ${cap} bytes`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const buffer = await readBodyWithCap(response, cap, filename);
|
|
129
|
+
const resolvedMediaType = mediaType ?? response.headers.get('content-type') ?? undefined;
|
|
130
|
+
|
|
131
|
+
const result: AttachmentDownloadResult = {
|
|
132
|
+
filename: basename(filename),
|
|
133
|
+
mediaType: resolvedMediaType,
|
|
134
|
+
size: buffer.length,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (options.destination) {
|
|
138
|
+
await writeFile(options.destination, buffer, { flag: 'wx' });
|
|
139
|
+
result.savedPath = options.destination;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const returnContent = options.returnContent ?? 'none';
|
|
143
|
+
if (returnContent !== 'none') {
|
|
144
|
+
const inlineCap = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
|
|
145
|
+
if (buffer.length > inlineCap) {
|
|
146
|
+
result.contentOmittedReason = `File size ${buffer.length} bytes exceeds inline cap of ${inlineCap} bytes`;
|
|
147
|
+
} else {
|
|
148
|
+
result.content = returnContent === 'base64' ? buffer.toString('base64') : buffer.toString('utf-8');
|
|
149
|
+
result.encoding = returnContent;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
import type { ProductDefinition } from './config/source.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Operator-controlled gateway that lets the attachment tools read from / write to
|
|
8
|
+
* the local filesystem. It is disabled by default: filesystem access must be
|
|
9
|
+
* explicitly enabled and confined to canonical root directories that the model
|
|
10
|
+
* cannot choose. Configured entirely through environment variables named after
|
|
11
|
+
* the product (e.g. JIRA_ATTACHMENTS_*).
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MiB
|
|
14
|
+
|
|
15
|
+
export interface AttachmentGatewaySide {
|
|
16
|
+
/** Whether this direction (upload/download) is enabled with a valid root. */
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** Canonical (realpath-resolved) absolute root directories. */
|
|
19
|
+
roots: string[];
|
|
20
|
+
/** Hard byte limit for a single file in this direction. */
|
|
21
|
+
maxBytes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AttachmentGateway {
|
|
25
|
+
upload: AttachmentGatewaySide;
|
|
26
|
+
download: AttachmentGatewaySide;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Env = Record<string, string | undefined>;
|
|
30
|
+
type Warn = (message: string) => void;
|
|
31
|
+
|
|
32
|
+
function envPrefix(product: ProductDefinition): string {
|
|
33
|
+
return product.id.toUpperCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readBool(env: Env, name: string): boolean {
|
|
37
|
+
const value = env[name]?.trim().toLowerCase();
|
|
38
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readBytes(env: Env, name: string): number | undefined {
|
|
42
|
+
const raw = env[name]?.trim();
|
|
43
|
+
if (!raw || !/^\d+$/.test(raw)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const parsed = Number.parseInt(raw, 10);
|
|
47
|
+
return parsed > 0 ? parsed : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readRoots(env: Env, names: string[]): string[] {
|
|
51
|
+
const roots: string[] = [];
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
const raw = env[name];
|
|
54
|
+
if (!raw) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const part of raw.split(delimiter)) {
|
|
58
|
+
const trimmed = part.trim();
|
|
59
|
+
if (trimmed) {
|
|
60
|
+
roots.push(trimmed);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return roots;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function canonicalizeRoots(rawRoots: string[], warn: Warn): string[] {
|
|
68
|
+
const canonical: string[] = [];
|
|
69
|
+
for (const root of rawRoots) {
|
|
70
|
+
if (!isAbsolute(root)) {
|
|
71
|
+
warn(`Ignoring attachment root that is not an absolute path: "${root}"`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const real = realpathSync(root);
|
|
76
|
+
if (!canonical.includes(real)) {
|
|
77
|
+
canonical.push(real);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
warn(`Ignoring attachment root that does not exist or cannot be resolved: "${root}"`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return canonical;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveSide(args: {
|
|
87
|
+
enabledFlag: boolean;
|
|
88
|
+
rawRoots: string[];
|
|
89
|
+
maxBytes: number;
|
|
90
|
+
label: string;
|
|
91
|
+
rootEnvHint: string;
|
|
92
|
+
warn: Warn;
|
|
93
|
+
}): AttachmentGatewaySide {
|
|
94
|
+
if (!args.enabledFlag) {
|
|
95
|
+
return { enabled: false, roots: [], maxBytes: args.maxBytes };
|
|
96
|
+
}
|
|
97
|
+
const roots = canonicalizeRoots(args.rawRoots, args.warn);
|
|
98
|
+
if (roots.length === 0) {
|
|
99
|
+
args.warn(
|
|
100
|
+
`${args.label} attachments were enabled but no valid directory is configured ` +
|
|
101
|
+
`(set ${args.rootEnvHint}); the ${args.label} tool will stay disabled.`,
|
|
102
|
+
);
|
|
103
|
+
return { enabled: false, roots: [], maxBytes: args.maxBytes };
|
|
104
|
+
}
|
|
105
|
+
return { enabled: true, roots, maxBytes: args.maxBytes };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reads the attachment gateway configuration for a product from the environment.
|
|
110
|
+
* Roots are canonicalized once here; a direction is only reported as enabled when
|
|
111
|
+
* its flag is set and at least one valid root resolves.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveAttachmentGateway(
|
|
114
|
+
product: ProductDefinition,
|
|
115
|
+
options?: { env?: Env; warn?: Warn },
|
|
116
|
+
): AttachmentGateway {
|
|
117
|
+
const env = options?.env ?? process.env;
|
|
118
|
+
const warn = options?.warn ?? ((message: string) => console.error(`[attachment-gateway] ${message}`));
|
|
119
|
+
const p = envPrefix(product);
|
|
120
|
+
|
|
121
|
+
const exchangeDir = env[`${p}_ATTACHMENTS_DIR`]?.trim();
|
|
122
|
+
const dirRoots = exchangeDir ? [exchangeDir] : [];
|
|
123
|
+
|
|
124
|
+
const upload = resolveSide({
|
|
125
|
+
enabledFlag: readBool(env, `${p}_ATTACHMENTS_UPLOAD_ENABLED`),
|
|
126
|
+
rawRoots: [...readRoots(env, [`${p}_ATTACHMENTS_UPLOAD_ROOTS`]), ...dirRoots],
|
|
127
|
+
maxBytes: readBytes(env, `${p}_ATTACHMENTS_MAX_UPLOAD_BYTES`) ?? DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
128
|
+
label: 'upload',
|
|
129
|
+
rootEnvHint: `${p}_ATTACHMENTS_UPLOAD_ROOTS or ${p}_ATTACHMENTS_DIR`,
|
|
130
|
+
warn,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const download = resolveSide({
|
|
134
|
+
enabledFlag: readBool(env, `${p}_ATTACHMENTS_DOWNLOAD_ENABLED`),
|
|
135
|
+
rawRoots: [...readRoots(env, [`${p}_ATTACHMENTS_DOWNLOAD_ROOTS`]), ...dirRoots],
|
|
136
|
+
maxBytes: readBytes(env, `${p}_ATTACHMENTS_MAX_DOWNLOAD_BYTES`) ?? DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
137
|
+
label: 'download',
|
|
138
|
+
rootEnvHint: `${p}_ATTACHMENTS_DOWNLOAD_ROOTS or ${p}_ATTACHMENTS_DIR`,
|
|
139
|
+
warn,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { upload, download };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeRelative(requested: string): string {
|
|
146
|
+
if (!requested || !requested.trim()) {
|
|
147
|
+
throw new Error('A file path is required');
|
|
148
|
+
}
|
|
149
|
+
if (isAbsolute(requested)) {
|
|
150
|
+
throw new Error(`Path must be relative to a configured attachment root, not absolute: "${requested}"`);
|
|
151
|
+
}
|
|
152
|
+
const norm = normalize(requested);
|
|
153
|
+
if (norm === '.' || norm === '..' || norm === '' || norm.startsWith(`..${sep}`) || norm.startsWith('../')) {
|
|
154
|
+
throw new Error(`Path must stay within the configured attachment root: "${requested}"`);
|
|
155
|
+
}
|
|
156
|
+
return norm;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function withinRoot(candidate: string, root: string): boolean {
|
|
160
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Canonicalizes the deepest existing ancestor of `target` (resolving symlinked
|
|
165
|
+
* directories) and re-attaches the not-yet-existing tail. Used to confirm a path
|
|
166
|
+
* cannot escape a root through a symlinked parent directory.
|
|
167
|
+
*/
|
|
168
|
+
async function canonicalizeDeepestExisting(target: string): Promise<string> {
|
|
169
|
+
let current = target;
|
|
170
|
+
while (true) {
|
|
171
|
+
try {
|
|
172
|
+
const real = await realpath(current);
|
|
173
|
+
const tail = relative(current, target);
|
|
174
|
+
return tail ? join(real, tail) : real;
|
|
175
|
+
} catch {
|
|
176
|
+
const parent = dirname(current);
|
|
177
|
+
if (parent === current) {
|
|
178
|
+
throw new Error(`Cannot resolve path: "${target}"`);
|
|
179
|
+
}
|
|
180
|
+
current = parent;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolves `requested` (relative) against the allowed roots, returning an absolute
|
|
187
|
+
* path whose parent directory is canonically inside a root. The final path
|
|
188
|
+
* component is NOT symlink-resolved, so callers can still detect a symlinked leaf.
|
|
189
|
+
*/
|
|
190
|
+
async function resolveLeafWithinRoots(requested: string, roots: string[]): Promise<string> {
|
|
191
|
+
const norm = normalizeRelative(requested);
|
|
192
|
+
for (const root of roots) {
|
|
193
|
+
const lexical = resolve(root, norm);
|
|
194
|
+
if (!withinRoot(lexical, root)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const parentReal = await canonicalizeDeepestExisting(dirname(lexical));
|
|
198
|
+
if (withinRoot(parentReal, root)) {
|
|
199
|
+
return join(parentReal, basename(lexical));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`Path resolves outside the configured attachment root(s): "${requested}"`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolves and validates a file to upload: it must live inside an allowed upload
|
|
207
|
+
* root, be a regular file (never a symlink or special file), and be within the
|
|
208
|
+
* size limit. Returns the absolute path to read.
|
|
209
|
+
*/
|
|
210
|
+
export async function resolveUploadSource(params: {
|
|
211
|
+
requestedPath: string;
|
|
212
|
+
side: AttachmentGatewaySide;
|
|
213
|
+
}): Promise<{ absolutePath: string; size: number }> {
|
|
214
|
+
if (!params.side.enabled) {
|
|
215
|
+
throw new Error('Attachment upload from the local filesystem is disabled on this server');
|
|
216
|
+
}
|
|
217
|
+
const absolutePath = await resolveLeafWithinRoots(params.requestedPath, params.side.roots);
|
|
218
|
+
const info = await lstat(absolutePath);
|
|
219
|
+
if (info.isSymbolicLink()) {
|
|
220
|
+
throw new Error(`Refusing to upload a symlink: "${params.requestedPath}"`);
|
|
221
|
+
}
|
|
222
|
+
if (!info.isFile()) {
|
|
223
|
+
throw new Error(`Refusing to upload a non-regular file: "${params.requestedPath}"`);
|
|
224
|
+
}
|
|
225
|
+
if (info.size > params.side.maxBytes) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`File size ${info.size} bytes exceeds the configured upload limit of ${params.side.maxBytes} bytes`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return { absolutePath, size: info.size };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolves a destination path to save a downloaded attachment into. The file is
|
|
235
|
+
* placed in the first configured download root; the caller writes with an
|
|
236
|
+
* exclusive flag so existing files (including symlinks) are never overwritten.
|
|
237
|
+
*/
|
|
238
|
+
export async function resolveDownloadDestination(params: {
|
|
239
|
+
requestedName: string;
|
|
240
|
+
side: AttachmentGatewaySide;
|
|
241
|
+
}): Promise<string> {
|
|
242
|
+
if (!params.side.enabled) {
|
|
243
|
+
throw new Error('Saving attachments to the local filesystem is disabled on this server');
|
|
244
|
+
}
|
|
245
|
+
const safeName = basename(params.requestedName);
|
|
246
|
+
if (!safeName || safeName === '.' || safeName === '..') {
|
|
247
|
+
throw new Error(`Invalid attachment file name: "${params.requestedName}"`);
|
|
248
|
+
}
|
|
249
|
+
return resolveLeafWithinRoots(safeName, [params.side.roots[0]]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Resolves a path within roots, throwing if it escapes. Exported for verification/tests. */
|
|
253
|
+
export async function assertWithinRoots(requested: string, roots: string[]): Promise<string> {
|
|
254
|
+
return resolveLeafWithinRoots(requested, roots);
|
|
255
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
export * from './api-error-handler.js'
|
|
5
|
+
export * from './attachment-download.js'
|
|
6
|
+
export * from './attachment-gateway.js'
|
|
5
7
|
export * from './config/index.js';
|
|
6
8
|
export { runSetup, runSetupCli } from './setup-cli.js';
|
|
7
9
|
export { describeValidationError } from './setup/describe-error.js';
|