org-jira-mcp-adapter 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/confluence-service.mjs +67 -0
- package/index.mjs +49 -0
- package/jira-service.mjs +99 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,14 +22,15 @@
|
|
|
22
22
|
],
|
|
23
23
|
"env": {
|
|
24
24
|
"JIRA_HOST": "jira.example.com",
|
|
25
|
-
"JIRA_API_TOKEN": "your_pat_token"
|
|
25
|
+
"JIRA_API_TOKEN": "your_pat_token",
|
|
26
|
+
"CONFLUENCE_HOST": "https://confluence.example.com",
|
|
27
|
+
"CONFLUENCE_TOKEN": "your_confluence_pat"
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
|
-
```
|
|
31
32
|
|
|
32
|
-
> **Важно**: Замените `jira.example.com
|
|
33
|
+
> **Важно**: Замените `jira.example.com`, `your_pat_token` и настройки Confluence на ваши реальные данные.
|
|
33
34
|
|
|
34
35
|
### Переменные окружения
|
|
35
36
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export class ConfluenceService {
|
|
4
|
+
constructor(host, token) {
|
|
5
|
+
let baseUrl = host;
|
|
6
|
+
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
|
7
|
+
baseUrl = 'https://' + baseUrl;
|
|
8
|
+
}
|
|
9
|
+
this.baseUrl = baseUrl.replace(/\/$/, '') + '/rest/api';
|
|
10
|
+
this.token = token;
|
|
11
|
+
this.headers = {
|
|
12
|
+
'Authorization': `Bearer ${token}`,
|
|
13
|
+
'Accept': 'application/json',
|
|
14
|
+
'Content-Type': 'application/json'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async request(endpoint, options = {}) {
|
|
19
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
...options,
|
|
22
|
+
headers: {
|
|
23
|
+
...this.headers,
|
|
24
|
+
...options.headers
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const body = await response.text();
|
|
30
|
+
throw new Error(`Confluence API error: ${response.status} ${response.statusText} - ${body}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return response.json();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async searchPages(cql, limit = 10) {
|
|
37
|
+
const params = new URLSearchParams({
|
|
38
|
+
cql,
|
|
39
|
+
limit: limit.toString(),
|
|
40
|
+
expand: 'space,version,body.view'
|
|
41
|
+
});
|
|
42
|
+
return this.request(`/content/search?${params}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async getPage(pageId, expand) {
|
|
46
|
+
const expandParam = expand ? `&expand=${expand}` : '&expand=body.storage,version,space';
|
|
47
|
+
return this.request(`/content/${pageId}?${expandParam}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getSpace(spaceKey) {
|
|
51
|
+
return this.request(`/space/${spaceKey}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const confluenceToolSchemas = {
|
|
56
|
+
searchPages: {
|
|
57
|
+
cql: z.string().describe("Confluence Query Language (CQL) string"),
|
|
58
|
+
limit: z.number().optional().describe("Maximum number of results to return")
|
|
59
|
+
},
|
|
60
|
+
getPage: {
|
|
61
|
+
pageId: z.string().describe("Confluence Page ID"),
|
|
62
|
+
expand: z.string().optional().describe("Comma separated fields to expand")
|
|
63
|
+
},
|
|
64
|
+
getSpace: {
|
|
65
|
+
spaceKey: z.string().describe("Confluence Space Key")
|
|
66
|
+
}
|
|
67
|
+
};
|
package/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { connectServer, createMcpServer, formatToolResponse } from '@atlassian-dc-mcp/common';
|
|
3
3
|
import { JiraService, jiraToolSchemas } from './jira-service.mjs';
|
|
4
|
+
import { ConfluenceService, confluenceToolSchemas } from './confluence-service.mjs';
|
|
4
5
|
import * as process from 'node:process';
|
|
5
6
|
|
|
6
7
|
const missingEnvVars = JiraService.validateConfig();
|
|
@@ -12,12 +13,45 @@ if (missingEnvVars.length > 0) {
|
|
|
12
13
|
// Removing email from constructor as it is not needed for PAT auth
|
|
13
14
|
const jiraService = new JiraService(process.env.JIRA_HOST, process.env.JIRA_API_TOKEN, process.env.JIRA_API_BASE_PATH);
|
|
14
15
|
|
|
16
|
+
// Confluence setup - используем CONFLUENCE_TOKEN если есть, иначе пробуем JIRA_API_TOKEN
|
|
17
|
+
const confluenceToken = process.env.CONFLUENCE_TOKEN || process.env.JIRA_API_TOKEN;
|
|
18
|
+
const confluenceHost = process.env.CONFLUENCE_HOST;
|
|
19
|
+
|
|
20
|
+
if (!confluenceHost) {
|
|
21
|
+
console.error("Warning: CONFLUENCE_HOST is not set. Confluence tools will fail.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const confluenceService = new ConfluenceService(
|
|
25
|
+
confluenceHost || '',
|
|
26
|
+
confluenceToken
|
|
27
|
+
);
|
|
28
|
+
|
|
15
29
|
const server = createMcpServer({
|
|
16
30
|
name: "atlassian-jira-mcp",
|
|
17
31
|
version: "0.1.0"
|
|
18
32
|
});
|
|
19
33
|
|
|
20
34
|
const jiraInstanceType = "JIRA Data Center edition instance";
|
|
35
|
+
const confluenceInstanceType = "Confluence Data Center instance";
|
|
36
|
+
|
|
37
|
+
// --- Confluence Tools ---
|
|
38
|
+
|
|
39
|
+
server.tool("confluence_searchPages", `Search for Confluence pages using CQL in the ${confluenceInstanceType}`, confluenceToolSchemas.searchPages, async ({ cql, limit }) => {
|
|
40
|
+
const result = await confluenceService.searchPages(cql, limit);
|
|
41
|
+
return formatToolResponse(result);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
server.tool("confluence_getPage", `Get details of a Confluence page by its ID from the ${confluenceInstanceType}`, confluenceToolSchemas.getPage, async ({ pageId, expand }) => {
|
|
45
|
+
const result = await confluenceService.getPage(pageId, expand);
|
|
46
|
+
return formatToolResponse(result);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
server.tool("confluence_getSpace", `Get details of a Confluence space by its key from the ${confluenceInstanceType}`, confluenceToolSchemas.getSpace, async ({ spaceKey }) => {
|
|
50
|
+
const result = await confluenceService.getSpace(spaceKey);
|
|
51
|
+
return formatToolResponse(result);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// --- Jira Tools ---
|
|
21
55
|
|
|
22
56
|
server.tool("jira_searchIssues", `Search for JIRA issues using JQL in the ${jiraInstanceType}`, jiraToolSchemas.searchIssues, async ({ jql, expand, startAt, maxResults = 10 }) => {
|
|
23
57
|
const result = await jiraService.searchIssues(jql, startAt, expand, maxResults);
|
|
@@ -124,4 +158,19 @@ server.tool("jira_findUsers", `Find users in the ${jiraInstanceType}`, jiraToolS
|
|
|
124
158
|
return formatToolResponse(result);
|
|
125
159
|
});
|
|
126
160
|
|
|
161
|
+
server.tool("jira_getIssueAttachments", `Get attachments for a JIRA issue in the ${jiraInstanceType}`, jiraToolSchemas.getIssueAttachments, async ({ issueKey }) => {
|
|
162
|
+
const result = await jiraService.getIssueAttachments(issueKey);
|
|
163
|
+
return formatToolResponse(result);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
server.tool("jira_getAttachmentContentUrl", `Get download URL for a JIRA attachment in the ${jiraInstanceType}`, jiraToolSchemas.getAttachmentContentUrl, async ({ attachmentId }) => {
|
|
167
|
+
const url = await jiraService.getAttachmentContentUrl(attachmentId);
|
|
168
|
+
return formatToolResponse({ url, attachmentId });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
server.tool("jira_addAttachment", `Add an attachment to a JIRA issue in the ${jiraInstanceType}`, jiraToolSchemas.addAttachment, async ({ issueKey, filePath }) => {
|
|
172
|
+
const result = await jiraService.addAttachment(issueKey, filePath);
|
|
173
|
+
return formatToolResponse(result);
|
|
174
|
+
});
|
|
175
|
+
|
|
127
176
|
await connectServer(server);
|
package/jira-service.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { handleApiOperation } from '@atlassian-dc-mcp/common';
|
|
3
|
-
import { IssueService, OpenAPI, SearchService } from '@atlassian-dc-mcp/jira/build/jira-client/index.js';
|
|
3
|
+
import { IssueService, OpenAPI, SearchService, ProjectService, MyselfService, GroupuserpickerService } from '@atlassian-dc-mcp/jira/build/jira-client/index.js';
|
|
4
|
+
import { request } from '@atlassian-dc-mcp/jira/build/jira-client/core/request.js';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
4
7
|
|
|
5
8
|
export class JiraService {
|
|
6
9
|
constructor(host, token, fullBaseUrl) {
|
|
@@ -130,6 +133,91 @@ export class JiraService {
|
|
|
130
133
|
return handleApiOperation(() => GroupuserpickerService.findUsersAndGroups(undefined, maxResults, query, undefined, undefined, undefined), 'Error finding users');
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
async getIssueAttachments(issueKey) {
|
|
137
|
+
return handleApiOperation(async () => {
|
|
138
|
+
const issue = await IssueService.getIssue(issueKey, ['attachment']);
|
|
139
|
+
const attachments = issue.fields?.attachment || [];
|
|
140
|
+
// Attachment'ы уже содержат поле 'content' с URL для скачивания
|
|
141
|
+
return attachments;
|
|
142
|
+
}, 'Error getting issue attachments');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getAttachmentContentUrl(attachmentId) {
|
|
146
|
+
// Возвращает URL для скачивания attachment'а
|
|
147
|
+
// В Jira REST API attachment'ы доступны через /secure/attachment/{id}
|
|
148
|
+
const baseUrl = OpenAPI.BASE.replace('/rest', '');
|
|
149
|
+
return `${baseUrl}/secure/attachment/${attachmentId}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async addAttachment(issueKey, filePath) {
|
|
153
|
+
return handleApiOperation(async () => {
|
|
154
|
+
// Проверяем существование файла
|
|
155
|
+
if (!fs.existsSync(filePath)) {
|
|
156
|
+
throw new Error(`File not found: ${filePath}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Читаем файл
|
|
160
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
161
|
+
const fileName = path.basename(filePath);
|
|
162
|
+
|
|
163
|
+
// Создаем File из буфера (в Node.js 18+ доступен глобальный File)
|
|
164
|
+
// File наследуется от Blob и содержит имя файла
|
|
165
|
+
let file;
|
|
166
|
+
if (typeof File !== 'undefined') {
|
|
167
|
+
file = new File([fileBuffer], fileName, {
|
|
168
|
+
type: this.getMimeType(filePath)
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
// Если File недоступен, создаем Blob и добавляем имя через специальный объект
|
|
172
|
+
const blob = new Blob([fileBuffer], {
|
|
173
|
+
type: this.getMimeType(filePath)
|
|
174
|
+
});
|
|
175
|
+
// Создаем объект, который будет правильно обработан FormData
|
|
176
|
+
file = Object.assign(blob, { name: fileName });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Для Jira API нужно передать объект с ключом 'file' и значением File/Blob
|
|
180
|
+
// Используем request напрямую для правильной обработки multipart/form-data
|
|
181
|
+
// Также нужно добавить заголовок X-Atlassian-Token: no-check для защиты от XSRF
|
|
182
|
+
return request(OpenAPI, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
url: '/api/2/issue/{issueIdOrKey}/attachments',
|
|
185
|
+
path: {
|
|
186
|
+
'issueIdOrKey': issueKey,
|
|
187
|
+
},
|
|
188
|
+
headers: {
|
|
189
|
+
'X-Atlassian-Token': 'no-check'
|
|
190
|
+
},
|
|
191
|
+
formData: {
|
|
192
|
+
'file': file
|
|
193
|
+
},
|
|
194
|
+
mediaType: 'multipart/form-data',
|
|
195
|
+
errors: {
|
|
196
|
+
403: `Returned if attachments is disabled or if you don't have permission to add attachments to this issue.`,
|
|
197
|
+
404: `Returned if the requested issue is not found, the user does not have permission to view it, or if the attachments exceeds the maximum configured attachment size.`,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}, 'Error adding attachment');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getMimeType(filePath) {
|
|
204
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
205
|
+
const mimeTypes = {
|
|
206
|
+
'.png': 'image/png',
|
|
207
|
+
'.jpg': 'image/jpeg',
|
|
208
|
+
'.jpeg': 'image/jpeg',
|
|
209
|
+
'.gif': 'image/gif',
|
|
210
|
+
'.pdf': 'application/pdf',
|
|
211
|
+
'.txt': 'text/plain',
|
|
212
|
+
'.doc': 'application/msword',
|
|
213
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
214
|
+
'.xls': 'application/vnd.ms-excel',
|
|
215
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
216
|
+
'.zip': 'application/zip',
|
|
217
|
+
};
|
|
218
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
219
|
+
}
|
|
220
|
+
|
|
133
221
|
static validateConfig() {
|
|
134
222
|
const requiredEnvVars = ['JIRA_API_TOKEN'];
|
|
135
223
|
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
|
|
@@ -227,5 +315,15 @@ export const jiraToolSchemas = {
|
|
|
227
315
|
description: z.string().optional().describe("New description in JIRA Wiki Markup (optional)"),
|
|
228
316
|
issueTypeId: z.string().optional().describe("New issue type id (optional)"),
|
|
229
317
|
customFields: z.object({}).catchall(z.any()).optional().describe("Optional custom fields to update as key-value pairs. Examples: {'customfield_10001': 'Custom Value', 'priority': {'id': '1'}, 'assignee': {'name': 'john.doe'}, 'labels': ['urgent', 'bug']}")
|
|
318
|
+
},
|
|
319
|
+
getIssueAttachments: {
|
|
320
|
+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)")
|
|
321
|
+
},
|
|
322
|
+
getAttachmentContentUrl: {
|
|
323
|
+
attachmentId: z.string().describe("Attachment ID from JIRA issue")
|
|
324
|
+
},
|
|
325
|
+
addAttachment: {
|
|
326
|
+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)"),
|
|
327
|
+
filePath: z.string().describe("Path to the file to attach (absolute or relative path)")
|
|
230
328
|
}
|
|
231
329
|
};
|