@atlassian-dc-mcp/jira 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 +29 -0
- package/README.md +77 -0
- package/build/__tests__/jira-download.test.d.ts +2 -0
- package/build/__tests__/jira-download.test.d.ts.map +1 -0
- package/build/__tests__/jira-download.test.js +89 -0
- package/build/__tests__/jira-download.test.js.map +1 -0
- package/build/__tests__/jira-service.test.js +85 -1
- package/build/__tests__/jira-service.test.js.map +1 -1
- package/build/__tests__/jira-upload.test.d.ts +2 -0
- package/build/__tests__/jira-upload.test.d.ts.map +1 -0
- package/build/__tests__/jira-upload.test.js +58 -0
- package/build/__tests__/jira-upload.test.js.map +1 -0
- package/build/__tests__/response-body.test.d.ts +2 -0
- package/build/__tests__/response-body.test.d.ts.map +1 -0
- package/build/__tests__/response-body.test.js +34 -0
- package/build/__tests__/response-body.test.js.map +1 -0
- package/build/index.js +38 -2
- package/build/index.js.map +1 -1
- package/build/jira-client/core/request.d.ts.map +1 -1
- package/build/jira-client/core/request.js +5 -1
- package/build/jira-client/core/request.js.map +1 -1
- package/build/jira-service.d.ts +63 -0
- package/build/jira-service.d.ts.map +1 -1
- package/build/jira-service.js +126 -2
- package/build/jira-service.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/jira-download.test.ts +111 -0
- package/src/__tests__/jira-service.test.ts +106 -1
- package/src/__tests__/jira-upload.test.ts +70 -0
- package/src/__tests__/response-body.test.ts +43 -0
- package/src/index.ts +77 -2
- package/src/jira-client/core/request.ts +5 -1
- package/src/jira-service.ts +170 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
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 { JiraService } from '../jira-service.js';
|
|
6
|
+
import { IssueService } from '../jira-client/index.js';
|
|
7
|
+
|
|
8
|
+
jest.mock('../jira-client/index.js', () => ({
|
|
9
|
+
AttachmentService: {},
|
|
10
|
+
IssueService: {
|
|
11
|
+
addAttachment: jest.fn(),
|
|
12
|
+
},
|
|
13
|
+
MyselfService: {},
|
|
14
|
+
SearchService: {},
|
|
15
|
+
OpenAPI: { BASE: '', TOKEN: '', VERSION: '' },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe('JiraService.uploadAttachment', () => {
|
|
19
|
+
let service: JiraService;
|
|
20
|
+
let root: string;
|
|
21
|
+
const uploadSide = (): AttachmentGatewaySide => ({ enabled: true, roots: [fs.realpathSync(root)], maxBytes: 1024 });
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
service = new JiraService('test-host', 'test-token', undefined, () => 25);
|
|
25
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mcp-jup-'));
|
|
26
|
+
jest.clearAllMocks();
|
|
27
|
+
(IssueService.addAttachment as jest.Mock).mockResolvedValue([{ id: '1', filename: 'a.txt' }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
32
|
+
jest.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('uploads a file resolved within the upload root', async () => {
|
|
36
|
+
fs.writeFileSync(path.join(root, 'a.txt'), 'hello');
|
|
37
|
+
|
|
38
|
+
const result = await service.uploadAttachment('PROJ-1', 'a.txt', uploadSide());
|
|
39
|
+
|
|
40
|
+
expect(result.success).toBe(true);
|
|
41
|
+
expect(IssueService.addAttachment).toHaveBeenCalledTimes(1);
|
|
42
|
+
const [issueKey, formData] = (IssueService.addAttachment as jest.Mock).mock.calls[0];
|
|
43
|
+
expect(issueKey).toBe('PROJ-1');
|
|
44
|
+
expect(formData.file.name).toBe('a.txt');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects an absolute path outside the root', async () => {
|
|
48
|
+
const result = await service.uploadAttachment('PROJ-1', '/etc/passwd', uploadSide());
|
|
49
|
+
expect(result.success).toBe(false);
|
|
50
|
+
expect(IssueService.addAttachment).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects a symlink inside the root', async () => {
|
|
54
|
+
const target = path.join(root, 'real.txt');
|
|
55
|
+
fs.writeFileSync(target, 'secret');
|
|
56
|
+
fs.symlinkSync(target, path.join(root, 'link.txt'));
|
|
57
|
+
|
|
58
|
+
const result = await service.uploadAttachment('PROJ-1', 'link.txt', uploadSide());
|
|
59
|
+
expect(result.success).toBe(false);
|
|
60
|
+
expect(IssueService.addAttachment).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects a file over the size limit', async () => {
|
|
64
|
+
fs.writeFileSync(path.join(root, 'big.txt'), 'x'.repeat(2048));
|
|
65
|
+
|
|
66
|
+
const result = await service.uploadAttachment('PROJ-1', 'big.txt', uploadSide());
|
|
67
|
+
expect(result.success).toBe(false);
|
|
68
|
+
expect(IssueService.addAttachment).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { getResponseBody } from '../jira-client/core/request.js';
|
|
2
|
+
|
|
3
|
+
function jsonResponse(status: number, body: string): Response {
|
|
4
|
+
return {
|
|
5
|
+
status,
|
|
6
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
7
|
+
text: async () => body,
|
|
8
|
+
json: async () => JSON.parse(body),
|
|
9
|
+
} as unknown as Response;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('getResponseBody', () => {
|
|
13
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
errorSpy.mockClear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
errorSpy.mockRestore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns undefined for an empty JSON body without logging (e.g. POST /issueLink)', async () => {
|
|
24
|
+
const result = await getResponseBody(jsonResponse(201, ''));
|
|
25
|
+
|
|
26
|
+
expect(result).toBeUndefined();
|
|
27
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('parses a non-empty JSON body', async () => {
|
|
31
|
+
const result = await getResponseBody(jsonResponse(200, '{"id":"1158012"}'));
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual({ id: '1158012' });
|
|
34
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns undefined for a 204 No Content response (e.g. DELETE /issueLink)', async () => {
|
|
38
|
+
const result = await getResponseBody(jsonResponse(204, ''));
|
|
39
|
+
|
|
40
|
+
expect(result).toBeUndefined();
|
|
41
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 { JiraService, jiraToolSchemas } from './jira-service.js';
|
|
3
|
-
import { getDefaultPageSize, getJiraRuntimeConfig } from './config.js';
|
|
3
|
+
import { getDefaultPageSize, getJiraRuntimeConfig, JIRA_PRODUCT } from './config.js';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
@@ -117,4 +117,79 @@ server.tool(
|
|
|
117
117
|
}
|
|
118
118
|
);
|
|
119
119
|
|
|
120
|
+
server.tool(
|
|
121
|
+
"jira_getIssueLinkTypes",
|
|
122
|
+
`Get the list of available issue link types (e.g. Blocks, Relates, Duplicate) in the ${jiraInstanceType}. Use this to discover valid link type names before calling jira_linkIssues.`,
|
|
123
|
+
jiraToolSchemas.getIssueLinkTypes,
|
|
124
|
+
async () => {
|
|
125
|
+
const result = await jiraService.getIssueLinkTypes();
|
|
126
|
+
return formatToolResponse(result);
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
server.tool(
|
|
131
|
+
"jira_linkIssues",
|
|
132
|
+
`Create a link between two JIRA issues in the ${jiraInstanceType}. Use jira_getIssueLinkTypes to find valid link type names.`,
|
|
133
|
+
jiraToolSchemas.linkIssues,
|
|
134
|
+
async (params) => {
|
|
135
|
+
const result = await jiraService.linkIssues(params);
|
|
136
|
+
return formatToolResponse(result);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
server.tool(
|
|
141
|
+
"jira_unlinkIssues",
|
|
142
|
+
`Delete an existing link between two JIRA issues in the ${jiraInstanceType}. Requires the issue link id (found in the 'issuelinks' field of an issue).`,
|
|
143
|
+
jiraToolSchemas.unlinkIssues,
|
|
144
|
+
async ({ linkId }) => {
|
|
145
|
+
const result = await jiraService.unlinkIssues(linkId);
|
|
146
|
+
return formatToolResponse(result);
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const attachmentGateway = resolveAttachmentGateway(JIRA_PRODUCT);
|
|
151
|
+
|
|
152
|
+
// Filesystem-reading upload is only registered when the operator explicitly enables it.
|
|
153
|
+
if (attachmentGateway.upload.enabled) {
|
|
154
|
+
server.tool(
|
|
155
|
+
"jira_uploadAttachment",
|
|
156
|
+
`Upload a local file as an attachment to a JIRA issue in the ${jiraInstanceType}. The file must live under the server-configured upload directory; the path is given relative to that directory.`,
|
|
157
|
+
jiraToolSchemas.uploadAttachment,
|
|
158
|
+
async ({ issueKey, sourcePath, filename }) => {
|
|
159
|
+
const result = await jiraService.uploadAttachment(issueKey, sourcePath, attachmentGateway.upload, filename);
|
|
160
|
+
return formatToolResponse(result);
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Download always returns content inline (no filesystem access). Saving to disk is
|
|
166
|
+
// only offered when the operator enables it, and is confined to the configured directory.
|
|
167
|
+
const downloadSaveEnabled = attachmentGateway.download.enabled;
|
|
168
|
+
server.tool(
|
|
169
|
+
"jira_downloadAttachment",
|
|
170
|
+
`Download attachment(s) from a JIRA issue in the ${jiraInstanceType}, by issue key (optionally filtered by filename) or by a single attachment id. Returns the file content inline (base64 or text). Useful for inspecting a file or moving it elsewhere (e.g. re-uploading to a Confluence page).${downloadSaveEnabled ? ' Can also save into the server-configured download directory; existing files are never overwritten.' : ' Saving to local disk is disabled on this server.'}`,
|
|
171
|
+
{ ...jiraToolSchemas.downloadAttachment, ...(downloadSaveEnabled ? jiraToolSchemas.downloadAttachmentSaveFields : {}) },
|
|
172
|
+
async ({ issueKey, attachmentId, filename, returnContent, maxInlineBytes, save, saveName }: {
|
|
173
|
+
issueKey?: string;
|
|
174
|
+
attachmentId?: string;
|
|
175
|
+
filename?: string;
|
|
176
|
+
returnContent?: 'none' | 'base64' | 'text';
|
|
177
|
+
maxInlineBytes?: number;
|
|
178
|
+
save?: boolean;
|
|
179
|
+
saveName?: string;
|
|
180
|
+
}) => {
|
|
181
|
+
const result = await jiraService.downloadAttachments({
|
|
182
|
+
issueKey,
|
|
183
|
+
attachmentId,
|
|
184
|
+
filename,
|
|
185
|
+
save,
|
|
186
|
+
saveName,
|
|
187
|
+
returnContent,
|
|
188
|
+
maxInlineBytes,
|
|
189
|
+
downloadSide: attachmentGateway.download,
|
|
190
|
+
});
|
|
191
|
+
return formatToolResponse(result);
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
|
|
120
195
|
await connectServer(server);
|
|
@@ -257,7 +257,11 @@ export const getResponseBody = async (response: Response): Promise<any> => {
|
|
|
257
257
|
const jsonTypes = ['application/json', 'application/problem+json']
|
|
258
258
|
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type));
|
|
259
259
|
if (isJSON) {
|
|
260
|
-
|
|
260
|
+
// Some endpoints (e.g. POST /issueLink) return a success status with
|
|
261
|
+
// Content-Type: application/json but an empty body; parsing that as JSON
|
|
262
|
+
// throws. Read the text first and only parse when there is something to parse.
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
return text ? JSON.parse(text) : undefined;
|
|
261
265
|
} else {
|
|
262
266
|
return await response.text();
|
|
263
267
|
}
|
package/src/jira-service.ts
CHANGED
|
@@ -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 {
|
|
3
|
-
|
|
5
|
+
import {
|
|
6
|
+
downloadAttachment,
|
|
7
|
+
handleApiOperation,
|
|
8
|
+
resolveDownloadDestination,
|
|
9
|
+
resolveOpenApiBase,
|
|
10
|
+
resolveUploadSource,
|
|
11
|
+
type AttachmentContentEncoding,
|
|
12
|
+
type AttachmentGatewaySide,
|
|
13
|
+
} from '@atlassian-dc-mcp/common';
|
|
14
|
+
import { AttachmentService, IssueLinkService, IssueLinkTypeService, IssueService, MyselfService, OpenAPI, SearchService } from './jira-client/index.js';
|
|
4
15
|
import { request as __request } from './jira-client/core/request.js';
|
|
5
16
|
import type { StringList } from './jira-client/models/StringList.js';
|
|
6
17
|
import { getDefaultPageSize, getMissingConfig, JIRA_PRODUCT } from './config.js';
|
|
@@ -28,6 +39,7 @@ function resolveToken(token: string | (() => string | undefined), missingTokenMe
|
|
|
28
39
|
|
|
29
40
|
export class JiraService {
|
|
30
41
|
private readonly getPageSize: () => number;
|
|
42
|
+
private readonly tokenProvider: string | (() => string | undefined);
|
|
31
43
|
|
|
32
44
|
constructor(
|
|
33
45
|
host: string | undefined,
|
|
@@ -43,6 +55,8 @@ export class JiraService {
|
|
|
43
55
|
});
|
|
44
56
|
OpenAPI.TOKEN = resolveToken(token, 'Missing required environment variable: JIRA_API_TOKEN');
|
|
45
57
|
OpenAPI.VERSION = '2';
|
|
58
|
+
OpenAPI.HEADERS = { 'X-Atlassian-Token': 'no-check' };
|
|
59
|
+
this.tokenProvider = token;
|
|
46
60
|
this.getPageSize = getPageSize;
|
|
47
61
|
}
|
|
48
62
|
|
|
@@ -177,6 +191,134 @@ export class JiraService {
|
|
|
177
191
|
}, 'Error transitioning issue');
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
async uploadAttachment(issueKey: string, sourcePath: string, uploadSide: AttachmentGatewaySide, filename?: string) {
|
|
195
|
+
return handleApiOperation(async () => {
|
|
196
|
+
const { absolutePath } = await resolveUploadSource({ requestedPath: sourcePath, side: uploadSide });
|
|
197
|
+
const buffer = await readFile(absolutePath);
|
|
198
|
+
const name = filename || basename(absolutePath);
|
|
199
|
+
const file = new File([buffer], name);
|
|
200
|
+
// IssueService types formData as Blob, but the API expects { file } — getFormData handles File via isBlob()
|
|
201
|
+
return IssueService.addAttachment(issueKey, { file } as any);
|
|
202
|
+
}, 'Error uploading attachment');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Download attachments from a JIRA issue, or a single attachment by its id.
|
|
207
|
+
* Exactly one of `attachmentId` or `issueKey` must be provided.
|
|
208
|
+
* @param params.attachmentId Download a single attachment by its numeric id
|
|
209
|
+
* @param params.issueKey Download attachments from this issue (optionally filtered by filename)
|
|
210
|
+
* @param params.filename When using issueKey, download only attachments with this exact filename
|
|
211
|
+
* @param params.save Whether to write the attachment(s) to the operator-configured download directory
|
|
212
|
+
* @param params.saveName Optional file name (basename only) when saving a single attachment
|
|
213
|
+
* @param params.returnContent Whether/how to embed the bytes inline in the response
|
|
214
|
+
* @param params.maxInlineBytes Inline embedding cap
|
|
215
|
+
* @param params.downloadSide Resolved download gateway (roots, size limit, enabled flag)
|
|
216
|
+
*/
|
|
217
|
+
async downloadAttachments(params: {
|
|
218
|
+
attachmentId?: string;
|
|
219
|
+
issueKey?: string;
|
|
220
|
+
filename?: string;
|
|
221
|
+
save?: boolean;
|
|
222
|
+
saveName?: string;
|
|
223
|
+
returnContent?: AttachmentContentEncoding;
|
|
224
|
+
maxInlineBytes?: number;
|
|
225
|
+
downloadSide: AttachmentGatewaySide;
|
|
226
|
+
}) {
|
|
227
|
+
return handleApiOperation(async () => {
|
|
228
|
+
if (params.save && !params.downloadSide.enabled) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'Saving attachments to disk is disabled on this server. Enable it with ' +
|
|
231
|
+
'JIRA_ATTACHMENTS_DOWNLOAD_ENABLED and configure a download directory.',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const beans = await this.resolveAttachmentBeans(params);
|
|
235
|
+
const multiple = beans.length > 1;
|
|
236
|
+
const attachments = [];
|
|
237
|
+
for (const bean of beans) {
|
|
238
|
+
const url = bean?.content;
|
|
239
|
+
if (!url) {
|
|
240
|
+
throw new Error(`Attachment "${bean?.filename ?? bean?.id}" has no download URL`);
|
|
241
|
+
}
|
|
242
|
+
const filename = bean?.filename ?? String(bean?.id ?? 'attachment');
|
|
243
|
+
let destination: string | undefined;
|
|
244
|
+
if (params.save) {
|
|
245
|
+
const requestedName = multiple ? filename : params.saveName ?? filename;
|
|
246
|
+
destination = await resolveDownloadDestination({ requestedName, side: params.downloadSide });
|
|
247
|
+
}
|
|
248
|
+
attachments.push(
|
|
249
|
+
await downloadAttachment({
|
|
250
|
+
url,
|
|
251
|
+
token: this.tokenProvider,
|
|
252
|
+
filename,
|
|
253
|
+
mediaType: bean?.mimeType,
|
|
254
|
+
options: {
|
|
255
|
+
destination,
|
|
256
|
+
returnContent: params.returnContent,
|
|
257
|
+
maxInlineBytes: params.maxInlineBytes,
|
|
258
|
+
maxDownloadBytes: params.downloadSide.maxBytes,
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return { count: attachments.length, attachments };
|
|
264
|
+
}, 'Error downloading attachment');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async resolveAttachmentBeans(params: {
|
|
268
|
+
attachmentId?: string;
|
|
269
|
+
issueKey?: string;
|
|
270
|
+
filename?: string;
|
|
271
|
+
}): Promise<Array<Record<string, any>>> {
|
|
272
|
+
if (params.attachmentId) {
|
|
273
|
+
const bean = await AttachmentService.getAttachment(params.attachmentId);
|
|
274
|
+
return [bean as Record<string, any>];
|
|
275
|
+
}
|
|
276
|
+
if (!params.issueKey) {
|
|
277
|
+
throw new Error('Either attachmentId or issueKey must be provided');
|
|
278
|
+
}
|
|
279
|
+
const issue = await IssueService.getIssue(params.issueKey, undefined, toIssueFieldSelection(['attachment']));
|
|
280
|
+
const all = (((issue as any)?.fields?.attachment) ?? []) as Array<Record<string, any>>;
|
|
281
|
+
const filtered = params.filename ? all.filter((a) => a?.filename === params.filename) : all;
|
|
282
|
+
if (filtered.length === 0) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
params.filename
|
|
285
|
+
? `No attachment named "${params.filename}" found on issue ${params.issueKey}`
|
|
286
|
+
: `No attachments found on issue ${params.issueKey}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return filtered;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async getIssueLinkTypes() {
|
|
293
|
+
return handleApiOperation(
|
|
294
|
+
() => IssueLinkTypeService.getIssueLinkTypes(),
|
|
295
|
+
'Error getting issue link types'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async linkIssues(params: {
|
|
300
|
+
inwardIssueKey: string;
|
|
301
|
+
outwardIssueKey: string;
|
|
302
|
+
linkType: string;
|
|
303
|
+
comment?: string;
|
|
304
|
+
}) {
|
|
305
|
+
return handleApiOperation(() => {
|
|
306
|
+
return IssueLinkService.linkIssues({
|
|
307
|
+
type: { name: params.linkType },
|
|
308
|
+
inwardIssue: { key: params.inwardIssueKey },
|
|
309
|
+
outwardIssue: { key: params.outwardIssueKey },
|
|
310
|
+
...(params.comment ? { comment: { body: params.comment } } : {}),
|
|
311
|
+
});
|
|
312
|
+
}, 'Error linking issues');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async unlinkIssues(linkId: string) {
|
|
316
|
+
return handleApiOperation(
|
|
317
|
+
() => IssueLinkService.deleteIssueLink(linkId),
|
|
318
|
+
'Error unlinking issues'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
180
322
|
async validateSetup(): Promise<void> {
|
|
181
323
|
await MyselfService.getUser();
|
|
182
324
|
}
|
|
@@ -236,5 +378,31 @@ export const jiraToolSchemas = {
|
|
|
236
378
|
transitionId: z.string().describe("The ID of the transition to perform. Use jira_getTransitions to find available transitions and their IDs."),
|
|
237
379
|
fields: z.record(z.any()).optional().describe("Optional fields required by the transition screen. Use jira_getTransitions to see which fields are available for each transition."),
|
|
238
380
|
customFields: z.record(z.any()).optional().describe("Optional fields merged into the JIRA transition payload. Can be used for update operations such as comments. Example: {'update': {'comment': [{'add': {'body': 'text'}}]}}")
|
|
381
|
+
},
|
|
382
|
+
uploadAttachment: {
|
|
383
|
+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)"),
|
|
384
|
+
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."),
|
|
385
|
+
filename: z.string().optional().describe("Override for the attachment filename (defaults to the basename of sourcePath)")
|
|
386
|
+
},
|
|
387
|
+
downloadAttachment: {
|
|
388
|
+
issueKey: z.string().optional().describe("JIRA issue key (e.g., PROJ-123) whose attachment(s) to download. Provide either issueKey or attachmentId."),
|
|
389
|
+
attachmentId: z.string().optional().describe("Numeric id of a single attachment to download. Provide either attachmentId or issueKey."),
|
|
390
|
+
filename: z.string().optional().describe("When using issueKey, download only attachments with this exact filename. If omitted, all attachments on the issue are downloaded."),
|
|
391
|
+
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."),
|
|
392
|
+
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.")
|
|
393
|
+
},
|
|
394
|
+
downloadAttachmentSaveFields: {
|
|
395
|
+
save: z.boolean().optional().describe("Save the attachment(s) into the server-configured download directory. Requires disk downloads to be enabled on the server."),
|
|
396
|
+
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.")
|
|
397
|
+
},
|
|
398
|
+
getIssueLinkTypes: {},
|
|
399
|
+
linkIssues: {
|
|
400
|
+
inwardIssueKey: z.string().describe("Key of the inward issue (the one the inward link description applies to, e.g. the issue that 'is blocked by'). Example: PROJ-123"),
|
|
401
|
+
outwardIssueKey: z.string().describe("Key of the outward issue (the one the outward link description applies to, e.g. the issue that 'blocks'). Example: PROJ-456"),
|
|
402
|
+
linkType: z.string().describe("Name of the issue link type to apply (e.g. 'Blocks', 'Relates', 'Duplicate'). Use jira_getIssueLinkTypes to discover valid names for this JIRA installation."),
|
|
403
|
+
comment: z.string().optional().describe("Optional comment added to the inward issue when the link is created, in JIRA Wiki Markup.")
|
|
404
|
+
},
|
|
405
|
+
unlinkIssues: {
|
|
406
|
+
linkId: z.string().describe("The id of the issue link to delete. Link ids can be found in the 'issuelinks' field of an issue (retrieve it via jira_getIssue with the 'issuelinks' field).")
|
|
239
407
|
}
|
|
240
408
|
};
|