@atlassian-dc-mcp/confluence 0.28.0 → 0.30.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 +22 -0
- package/README.md +57 -0
- package/build/__tests__/confluence-download.test.d.ts +2 -0
- package/build/__tests__/confluence-download.test.d.ts.map +1 -0
- package/build/__tests__/confluence-download.test.js +69 -0
- package/build/__tests__/confluence-download.test.js.map +1 -0
- package/build/__tests__/confluence-upload.test.d.ts +2 -0
- package/build/__tests__/confluence-upload.test.d.ts.map +1 -0
- package/build/__tests__/confluence-upload.test.js +59 -0
- package/build/__tests__/confluence-upload.test.js.map +1 -0
- package/build/confluence-service.d.ts +63 -0
- package/build/confluence-service.d.ts.map +1 -1
- package/build/confluence-service.js +135 -3
- package/build/confluence-service.js.map +1 -1
- package/build/index.js +25 -2
- package/build/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/confluence-download.test.ts +89 -0
- package/src/__tests__/confluence-upload.test.ts +71 -0
- package/src/confluence-service.ts +177 -3
- package/src/index.ts +55 -2
- package/tsconfig.tsbuildinfo +1 -1
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlassian-dc-mcp/confluence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./bin/run.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "jest --passWithNoTests"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@atlassian-dc-mcp/common": "^0.
|
|
34
|
+
"@atlassian-dc-mcp/common": "^0.30.0",
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
36
36
|
"dotenv": "^16.4.7",
|
|
37
37
|
"zod": "^3.24.2"
|
|
@@ -45,5 +45,5 @@
|
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "92a4fc60548de5ee374489deef4d09271536014f"
|
|
49
49
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { AttachmentGatewaySide } from '@atlassian-dc-mcp/common';
|
|
2
|
+
import { ConfluenceService } from '../confluence-service.js';
|
|
3
|
+
import { AttachmentsService } from '../confluence-client/index.js';
|
|
4
|
+
|
|
5
|
+
const DISABLED_DOWNLOAD: AttachmentGatewaySide = { enabled: false, roots: [], maxBytes: 25 * 1024 * 1024 };
|
|
6
|
+
|
|
7
|
+
jest.mock('../confluence-client/index.js', () => ({
|
|
8
|
+
AttachmentsService: {
|
|
9
|
+
getAttachments: jest.fn(),
|
|
10
|
+
},
|
|
11
|
+
ContentResourceService: {},
|
|
12
|
+
SearchService: {},
|
|
13
|
+
UserService: {},
|
|
14
|
+
OpenAPI: { BASE: '', TOKEN: '', VERSION: '' },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
function mockFetchText(text: string) {
|
|
18
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
statusText: 'OK',
|
|
22
|
+
headers: { get: () => 'text/plain' },
|
|
23
|
+
arrayBuffer: async () => Buffer.from(text),
|
|
24
|
+
}) as unknown as typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ConfluenceService.downloadAttachmentFromContent', () => {
|
|
28
|
+
let service: ConfluenceService;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
service = new ConfluenceService('test-host', 'test-token');
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => jest.restoreAllMocks());
|
|
36
|
+
|
|
37
|
+
it('builds an absolute download URL from the relative _links.download and returns content', async () => {
|
|
38
|
+
(AttachmentsService.getAttachments as jest.Mock).mockResolvedValue({
|
|
39
|
+
results: [
|
|
40
|
+
{ title: 'doc.txt', metadata: { mediaType: 'text/plain' }, _links: { download: '/download/attachments/123/doc.txt?version=1' } },
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
mockFetchText('file body');
|
|
44
|
+
|
|
45
|
+
const result = await service.downloadAttachmentFromContent({ contentId: '123', filename: 'doc.txt', returnContent: 'text', downloadSide: DISABLED_DOWNLOAD });
|
|
46
|
+
|
|
47
|
+
expect(AttachmentsService.getAttachments).toHaveBeenCalledWith('123', undefined, 'doc.txt');
|
|
48
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
49
|
+
'https://test-host/download/attachments/123/doc.txt?version=1',
|
|
50
|
+
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token' }) }),
|
|
51
|
+
);
|
|
52
|
+
expect(result.success).toBe(true);
|
|
53
|
+
expect(result.data).toMatchObject({ contentId: '123', count: 1 });
|
|
54
|
+
expect(result.data!.attachments[0]).toMatchObject({ filename: 'doc.txt', content: 'file body', mediaType: 'text/plain' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('downloads all attachments when no filename is given', async () => {
|
|
58
|
+
(AttachmentsService.getAttachments as jest.Mock).mockResolvedValue({
|
|
59
|
+
results: [
|
|
60
|
+
{ title: 'a.txt', _links: { download: '/download/attachments/123/a.txt' } },
|
|
61
|
+
{ title: 'b.txt', _links: { download: '/download/attachments/123/b.txt' } },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
mockFetchText('x');
|
|
65
|
+
|
|
66
|
+
const result = await service.downloadAttachmentFromContent({ contentId: '123', downloadSide: DISABLED_DOWNLOAD });
|
|
67
|
+
|
|
68
|
+
expect(result.success).toBe(true);
|
|
69
|
+
expect(result.data!.count).toBe(2);
|
|
70
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns a failure when the content has no matching attachment', async () => {
|
|
74
|
+
(AttachmentsService.getAttachments as jest.Mock).mockResolvedValue({ results: [] });
|
|
75
|
+
|
|
76
|
+
const result = await service.downloadAttachmentFromContent({ contentId: '123', filename: 'missing.txt', downloadSide: DISABLED_DOWNLOAD });
|
|
77
|
+
|
|
78
|
+
expect(result.success).toBe(false);
|
|
79
|
+
expect(result.error).toContain('No attachment named "missing.txt"');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('refuses to save to disk when downloads are disabled', async () => {
|
|
83
|
+
const result = await service.downloadAttachmentFromContent({ contentId: '123', filename: 'doc.txt', save: true, downloadSide: DISABLED_DOWNLOAD });
|
|
84
|
+
|
|
85
|
+
expect(result.success).toBe(false);
|
|
86
|
+
expect(result.error).toContain('disabled');
|
|
87
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { AttachmentGatewaySide } from '@atlassian-dc-mcp/common';
|
|
5
|
+
import { ConfluenceService } from '../confluence-service.js';
|
|
6
|
+
import { AttachmentsService } from '../confluence-client/index.js';
|
|
7
|
+
|
|
8
|
+
jest.mock('../confluence-client/index.js', () => ({
|
|
9
|
+
AttachmentsService: {
|
|
10
|
+
getAttachments: jest.fn(),
|
|
11
|
+
createAttachments: jest.fn(),
|
|
12
|
+
updateData: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
ContentResourceService: {},
|
|
15
|
+
SearchService: {},
|
|
16
|
+
UserService: {},
|
|
17
|
+
OpenAPI: { BASE: '', TOKEN: '', VERSION: '', HEADERS: undefined },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe('ConfluenceService.uploadAttachment', () => {
|
|
21
|
+
let service: ConfluenceService;
|
|
22
|
+
let root: string;
|
|
23
|
+
const uploadSide = (): AttachmentGatewaySide => ({ enabled: true, roots: [fs.realpathSync(root)], maxBytes: 1024 });
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
service = new ConfluenceService('test-host', 'test-token');
|
|
27
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-cup-'));
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
(AttachmentsService.createAttachments as jest.Mock).mockResolvedValue({ results: [{ id: '1', title: 'a.txt' }] });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
34
|
+
jest.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('uploads a file resolved within the upload root', async () => {
|
|
38
|
+
fs.writeFileSync(path.join(root, 'a.txt'), 'hello');
|
|
39
|
+
|
|
40
|
+
const result = await service.uploadAttachment('42', 'a.txt', uploadSide());
|
|
41
|
+
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(AttachmentsService.createAttachments).toHaveBeenCalledTimes(1);
|
|
44
|
+
const formData = (AttachmentsService.createAttachments as jest.Mock).mock.calls[0][4];
|
|
45
|
+
expect(formData.file.name).toBe('a.txt');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects an absolute path outside the root', async () => {
|
|
49
|
+
const result = await service.uploadAttachment('42', '/etc/passwd', uploadSide());
|
|
50
|
+
expect(result.success).toBe(false);
|
|
51
|
+
expect(AttachmentsService.createAttachments).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a symlink inside the root', async () => {
|
|
55
|
+
const target = path.join(root, 'real.txt');
|
|
56
|
+
fs.writeFileSync(target, 'secret');
|
|
57
|
+
fs.symlinkSync(target, path.join(root, 'link.txt'));
|
|
58
|
+
|
|
59
|
+
const result = await service.uploadAttachment('42', 'link.txt', uploadSide());
|
|
60
|
+
expect(result.success).toBe(false);
|
|
61
|
+
expect(AttachmentsService.createAttachments).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects a file over the size limit', async () => {
|
|
65
|
+
fs.writeFileSync(path.join(root, 'big.txt'), 'x'.repeat(2048));
|
|
66
|
+
|
|
67
|
+
const result = await service.uploadAttachment('42', 'big.txt', uploadSide());
|
|
68
|
+
expect(result.success).toBe(false);
|
|
69
|
+
expect(AttachmentsService.createAttachments).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import { File } from 'node:buffer';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
1
4
|
import { z } from 'zod';
|
|
2
|
-
import { ContentResourceService, OpenAPI, SearchService, UserService } from './confluence-client/index.js';
|
|
3
|
-
import {
|
|
5
|
+
import { AttachmentsService, ContentResourceService, OpenAPI, SearchService, UserService } from './confluence-client/index.js';
|
|
6
|
+
import {
|
|
7
|
+
downloadAttachment,
|
|
8
|
+
handleApiOperation,
|
|
9
|
+
resolveDownloadDestination,
|
|
10
|
+
resolveOpenApiBase,
|
|
11
|
+
resolveUploadSource,
|
|
12
|
+
type AttachmentContentEncoding,
|
|
13
|
+
type AttachmentGatewaySide,
|
|
14
|
+
} from '@atlassian-dc-mcp/common';
|
|
4
15
|
import { CONFLUENCE_PRODUCT, getDefaultPageSize, getMissingConfig } from './config.js';
|
|
5
16
|
import { ConfluenceBodyMode, shapeConfluenceContent } from './confluence-response-mapper.js';
|
|
6
17
|
|
|
@@ -45,6 +56,8 @@ function resolveToken(token: string | (() => string | undefined), missingTokenMe
|
|
|
45
56
|
|
|
46
57
|
export class ConfluenceService {
|
|
47
58
|
private readonly getPageSize: () => number;
|
|
59
|
+
private readonly baseUrl: string;
|
|
60
|
+
private readonly tokenProvider: string | (() => string | undefined);
|
|
48
61
|
|
|
49
62
|
constructor(
|
|
50
63
|
host: string | undefined,
|
|
@@ -52,16 +65,31 @@ export class ConfluenceService {
|
|
|
52
65
|
apiBasePath?: string,
|
|
53
66
|
getPageSize: () => number = getDefaultPageSize,
|
|
54
67
|
) {
|
|
55
|
-
|
|
68
|
+
const base = resolveOpenApiBase({
|
|
56
69
|
host,
|
|
57
70
|
apiBasePath,
|
|
58
71
|
defaultBasePath: CONFLUENCE_PRODUCT.defaultApiBasePath ?? '',
|
|
59
72
|
strippableSuffixes: CONFLUENCE_PRODUCT.apiBasePathStrippableSuffixes,
|
|
60
73
|
});
|
|
74
|
+
OpenAPI.BASE = base;
|
|
61
75
|
OpenAPI.TOKEN = resolveToken(token, 'Missing required environment variable: CONFLUENCE_API_TOKEN');
|
|
62
76
|
OpenAPI.VERSION = '1.0';
|
|
77
|
+
this.baseUrl = base;
|
|
78
|
+
this.tokenProvider = token;
|
|
63
79
|
this.getPageSize = getPageSize;
|
|
64
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds an absolute download URL from a Confluence attachment `_links.download`
|
|
84
|
+
* value, which is a path relative to the site base (context path included).
|
|
85
|
+
*/
|
|
86
|
+
private buildDownloadUrl(downloadLink: string): string {
|
|
87
|
+
if (/^https?:\/\//i.test(downloadLink)) {
|
|
88
|
+
return downloadLink;
|
|
89
|
+
}
|
|
90
|
+
const base = this.baseUrl.replace(/\/$/, '');
|
|
91
|
+
return `${base}${downloadLink.startsWith('/') ? '' : '/'}${downloadLink}`;
|
|
92
|
+
}
|
|
65
93
|
/**
|
|
66
94
|
* Get a Confluence page by ID
|
|
67
95
|
* @param contentId The ID of the page to retrieve
|
|
@@ -132,6 +160,132 @@ export class ConfluenceService {
|
|
|
132
160
|
return handleApiOperation(() => ContentResourceService.update2(contentId, content), 'Error updating content');
|
|
133
161
|
}
|
|
134
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Upload a file as an attachment to a Confluence content entity
|
|
165
|
+
* @param contentId The ID of the content the attachment will be attached to
|
|
166
|
+
* @param sourcePath Path to the file to upload, relative to a server-configured upload directory
|
|
167
|
+
* @param uploadSide Resolved upload gateway (roots, size limit, enabled flag)
|
|
168
|
+
* @param filename Optional override for the attachment filename (defaults to basename of sourcePath)
|
|
169
|
+
* @param comment Optional comment describing the attachment
|
|
170
|
+
* @param minorEdit If true, no notification email will be generated
|
|
171
|
+
* @param hidden If true, no notification email or activity stream entry will be generated
|
|
172
|
+
* @param allowDuplicated Allow upload even if an attachment with the same filename exists
|
|
173
|
+
*/
|
|
174
|
+
async uploadAttachment(
|
|
175
|
+
contentId: string,
|
|
176
|
+
sourcePath: string,
|
|
177
|
+
uploadSide: AttachmentGatewaySide,
|
|
178
|
+
filename?: string,
|
|
179
|
+
comment?: string,
|
|
180
|
+
minorEdit?: boolean,
|
|
181
|
+
hidden?: boolean,
|
|
182
|
+
allowDuplicated?: boolean,
|
|
183
|
+
versionIfExists?: boolean,
|
|
184
|
+
) {
|
|
185
|
+
return handleApiOperation(async () => {
|
|
186
|
+
const { absolutePath } = await resolveUploadSource({ requestedPath: sourcePath, side: uploadSide });
|
|
187
|
+
const buffer = await readFile(absolutePath);
|
|
188
|
+
const name = filename || basename(absolutePath);
|
|
189
|
+
const file = new File([buffer], name);
|
|
190
|
+
// X-Atlassian-Token: nocheck is required for multipart attachment POSTs (XSRF bypass).
|
|
191
|
+
// Set it only for the duration of this call; restore afterwards.
|
|
192
|
+
const prevHeaders = OpenAPI.HEADERS;
|
|
193
|
+
OpenAPI.HEADERS = { 'X-Atlassian-Token': 'nocheck' };
|
|
194
|
+
try {
|
|
195
|
+
if (versionIfExists) {
|
|
196
|
+
const existing = await AttachmentsService.getAttachments(contentId, undefined, name);
|
|
197
|
+
const existingId = (existing as any)?.results?.[0]?.id;
|
|
198
|
+
if (existingId) {
|
|
199
|
+
// MockAttachmentRequest types file as string, but getFormData handles Blob/File via isBlob()
|
|
200
|
+
return await AttachmentsService.updateData(existingId, contentId, { file } as any);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// MockAttachmentRequest types file as string, but getFormData in request.ts handles Blob/File via isBlob()
|
|
204
|
+
const formData = { file, comment, minorEdit, hidden } as any;
|
|
205
|
+
return await AttachmentsService.createAttachments(
|
|
206
|
+
contentId,
|
|
207
|
+
undefined,
|
|
208
|
+
allowDuplicated ? 'true' : undefined,
|
|
209
|
+
undefined,
|
|
210
|
+
formData,
|
|
211
|
+
);
|
|
212
|
+
} finally {
|
|
213
|
+
OpenAPI.HEADERS = prevHeaders;
|
|
214
|
+
}
|
|
215
|
+
}, 'Error uploading attachment');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Download one or more attachments from a Confluence content entity.
|
|
220
|
+
* @param params.contentId The ID of the content the attachment(s) are on
|
|
221
|
+
* @param params.filename If provided, download only the attachment with this exact filename; otherwise download all attachments on the content
|
|
222
|
+
* @param params.save Whether to write the attachment(s) to the operator-configured download directory
|
|
223
|
+
* @param params.saveName Optional file name (basename only) when saving a single attachment
|
|
224
|
+
* @param params.returnContent Whether/how to embed the bytes inline in the response
|
|
225
|
+
* @param params.maxInlineBytes Inline embedding cap
|
|
226
|
+
* @param params.downloadSide Resolved download gateway (roots, size limit, enabled flag)
|
|
227
|
+
*/
|
|
228
|
+
async downloadAttachmentFromContent(params: {
|
|
229
|
+
contentId: string;
|
|
230
|
+
filename?: string;
|
|
231
|
+
save?: boolean;
|
|
232
|
+
saveName?: string;
|
|
233
|
+
returnContent?: AttachmentContentEncoding;
|
|
234
|
+
maxInlineBytes?: number;
|
|
235
|
+
downloadSide: AttachmentGatewaySide;
|
|
236
|
+
}) {
|
|
237
|
+
return handleApiOperation(async () => {
|
|
238
|
+
if (params.save && !params.downloadSide.enabled) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
'Saving attachments to disk is disabled on this server. Enable it with ' +
|
|
241
|
+
'CONFLUENCE_ATTACHMENTS_DOWNLOAD_ENABLED and configure a download directory.',
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const list = await AttachmentsService.getAttachments(params.contentId, undefined, params.filename);
|
|
245
|
+
const results = ((list as any)?.results ?? []) as Array<any>;
|
|
246
|
+
if (results.length === 0) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
params.filename
|
|
249
|
+
? `No attachment named "${params.filename}" found on content ${params.contentId}`
|
|
250
|
+
: `No attachments found on content ${params.contentId}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const targets = params.filename ? [results[0]] : results;
|
|
255
|
+
const multiple = targets.length > 1;
|
|
256
|
+
const attachments = [];
|
|
257
|
+
for (const attachment of targets) {
|
|
258
|
+
const downloadLink = attachment?._links?.download;
|
|
259
|
+
if (!downloadLink) {
|
|
260
|
+
throw new Error(`Attachment "${attachment?.title ?? params.filename}" has no download link`);
|
|
261
|
+
}
|
|
262
|
+
const name = attachment?.title ?? params.filename ?? 'attachment';
|
|
263
|
+
const mediaType = attachment?.metadata?.mediaType ?? attachment?.extensions?.mediaType;
|
|
264
|
+
let destination: string | undefined;
|
|
265
|
+
if (params.save) {
|
|
266
|
+
const requestedName = multiple ? name : params.saveName ?? name;
|
|
267
|
+
destination = await resolveDownloadDestination({ requestedName, side: params.downloadSide });
|
|
268
|
+
}
|
|
269
|
+
attachments.push(
|
|
270
|
+
await downloadAttachment({
|
|
271
|
+
url: this.buildDownloadUrl(downloadLink),
|
|
272
|
+
token: this.tokenProvider,
|
|
273
|
+
filename: name,
|
|
274
|
+
mediaType,
|
|
275
|
+
options: {
|
|
276
|
+
destination,
|
|
277
|
+
returnContent: params.returnContent,
|
|
278
|
+
maxInlineBytes: params.maxInlineBytes,
|
|
279
|
+
maxDownloadBytes: params.downloadSide.maxBytes,
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { contentId: params.contentId, count: attachments.length, attachments };
|
|
286
|
+
}, 'Error downloading attachment');
|
|
287
|
+
}
|
|
288
|
+
|
|
135
289
|
/**
|
|
136
290
|
* Search for spaces by text
|
|
137
291
|
* @param searchText Text to search for in space names or descriptions
|
|
@@ -208,5 +362,25 @@ export const confluenceToolSchemas = {
|
|
|
208
362
|
start: z.number().optional().describe("Start index for pagination"),
|
|
209
363
|
expand: z.string().optional().describe("Comma-separated list of properties to expand"),
|
|
210
364
|
excerpt: z.enum(['none', 'highlight']).optional().describe("Excerpt mode for search results. Defaults to none.")
|
|
365
|
+
},
|
|
366
|
+
uploadAttachment: {
|
|
367
|
+
contentId: z.string().describe("ID of the Confluence content (page) to attach the file to"),
|
|
368
|
+
sourcePath: z.string().describe("Path to the file to upload, relative to a server-configured attachment upload directory. Absolute paths and '..' segments are rejected; symlinks and non-regular files are refused."),
|
|
369
|
+
filename: z.string().optional().describe("Override for the attachment filename (defaults to the basename of sourcePath)"),
|
|
370
|
+
comment: z.string().optional().describe("Optional comment describing the attachment"),
|
|
371
|
+
minorEdit: z.boolean().optional().describe("If true, no notification email is sent to watchers"),
|
|
372
|
+
hidden: z.boolean().optional().describe("If true, no notification email or activity stream entry is generated"),
|
|
373
|
+
allowDuplicated: z.boolean().optional().describe("Allow upload even if an attachment with the same filename already exists"),
|
|
374
|
+
versionIfExists: z.boolean().optional().describe("If true and an attachment with the same filename already exists, upload as a new version instead of failing")
|
|
375
|
+
},
|
|
376
|
+
downloadAttachment: {
|
|
377
|
+
contentId: z.string().describe("ID of the Confluence content (page) whose attachment(s) to download"),
|
|
378
|
+
filename: z.string().optional().describe("Exact filename of a single attachment to download. If omitted, all attachments on the content are downloaded."),
|
|
379
|
+
returnContent: z.enum(['none', 'base64', 'text']).optional().describe("Whether to embed the file bytes in the response: 'none' (default), 'base64' for binary, or 'text' for UTF-8 text."),
|
|
380
|
+
maxInlineBytes: z.number().optional().describe("Maximum bytes to embed inline when returnContent is base64/text. Larger files are omitted from the inline content. Defaults to 1 MiB.")
|
|
381
|
+
},
|
|
382
|
+
downloadAttachmentSaveFields: {
|
|
383
|
+
save: z.boolean().optional().describe("Save the attachment(s) into the server-configured download directory. Requires disk downloads to be enabled on the server."),
|
|
384
|
+
saveName: z.string().optional().describe("Optional file name (no directories) to use when saving a single attachment; defaults to the attachment's own name. Existing files are never overwritten.")
|
|
211
385
|
}
|
|
212
386
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig } from '@atlassian-dc-mcp/common';
|
|
1
|
+
import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig, resolveAttachmentGateway } from '@atlassian-dc-mcp/common';
|
|
2
2
|
import { ConfluenceService, ConfluenceContent, confluenceToolSchemas } from './confluence-service.js';
|
|
3
3
|
import { shapeConfluenceMutationAck } from './confluence-response-mapper.js';
|
|
4
|
-
import { getConfluenceRuntimeConfig, getDefaultPageSize } from './config.js';
|
|
4
|
+
import { CONFLUENCE_PRODUCT, getConfluenceRuntimeConfig, getDefaultPageSize } from './config.js';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
@@ -141,6 +141,59 @@ server.tool(
|
|
|
141
141
|
}
|
|
142
142
|
);
|
|
143
143
|
|
|
144
|
+
const attachmentGateway = resolveAttachmentGateway(CONFLUENCE_PRODUCT);
|
|
145
|
+
|
|
146
|
+
// Filesystem-reading upload is only registered when the operator explicitly enables it.
|
|
147
|
+
if (attachmentGateway.upload.enabled) {
|
|
148
|
+
server.tool(
|
|
149
|
+
"confluence_uploadAttachment",
|
|
150
|
+
`Upload a local file as an attachment to a Confluence content (page) in the ${confluenceInstanceType}. The file must live under the server-configured upload directory; the path is given relative to that directory.`,
|
|
151
|
+
confluenceToolSchemas.uploadAttachment,
|
|
152
|
+
async ({ contentId, sourcePath, filename, comment, minorEdit, hidden, allowDuplicated, versionIfExists }) => {
|
|
153
|
+
const result = await confluenceService.uploadAttachment(
|
|
154
|
+
contentId,
|
|
155
|
+
sourcePath,
|
|
156
|
+
attachmentGateway.upload,
|
|
157
|
+
filename,
|
|
158
|
+
comment,
|
|
159
|
+
minorEdit,
|
|
160
|
+
hidden,
|
|
161
|
+
allowDuplicated,
|
|
162
|
+
versionIfExists,
|
|
163
|
+
);
|
|
164
|
+
return formatToolResponse(result);
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Download always returns content inline (no filesystem access). Saving to disk is
|
|
170
|
+
// only offered when the operator enables it, and is confined to the configured directory.
|
|
171
|
+
const downloadSaveEnabled = attachmentGateway.download.enabled;
|
|
172
|
+
server.tool(
|
|
173
|
+
"confluence_downloadAttachment",
|
|
174
|
+
`Download one or more attachments from a Confluence content (page) in the ${confluenceInstanceType}. Returns the file content inline (base64 or text). Useful for inspecting a file or moving it elsewhere (e.g. re-uploading to a Jira issue).${downloadSaveEnabled ? ' Can also save into the server-configured download directory; existing files are never overwritten.' : ' Saving to local disk is disabled on this server.'}`,
|
|
175
|
+
{ ...confluenceToolSchemas.downloadAttachment, ...(downloadSaveEnabled ? confluenceToolSchemas.downloadAttachmentSaveFields : {}) },
|
|
176
|
+
async ({ contentId, filename, returnContent, maxInlineBytes, save, saveName }: {
|
|
177
|
+
contentId: string;
|
|
178
|
+
filename?: string;
|
|
179
|
+
returnContent?: 'none' | 'base64' | 'text';
|
|
180
|
+
maxInlineBytes?: number;
|
|
181
|
+
save?: boolean;
|
|
182
|
+
saveName?: string;
|
|
183
|
+
}) => {
|
|
184
|
+
const result = await confluenceService.downloadAttachmentFromContent({
|
|
185
|
+
contentId,
|
|
186
|
+
filename,
|
|
187
|
+
save,
|
|
188
|
+
saveName,
|
|
189
|
+
returnContent,
|
|
190
|
+
maxInlineBytes,
|
|
191
|
+
downloadSide: attachmentGateway.download,
|
|
192
|
+
});
|
|
193
|
+
return formatToolResponse(result);
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
144
197
|
server.tool('confluence_searchSpace',
|
|
145
198
|
`Search for spaces in ${confluenceInstanceType}`,
|
|
146
199
|
confluenceToolSchemas.searchSpaces,
|