@stubbedev/atlassian-mcp 0.0.5 → 0.1.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/README.md +36 -11
- package/dist/bitbucket.js +196 -38
- package/dist/context.js +0 -26
- package/dist/index.js +193 -176
- package/dist/jira.js +97 -12
- package/package.json +4 -2
package/dist/jira.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
const JIRA_KEY_IN_BRANCH_RE = /\b([A-Z][A-Z0-9]+)-\d+\b/;
|
|
1
3
|
function text(t) {
|
|
2
4
|
return { content: [{ type: 'text', text: t }] };
|
|
3
5
|
}
|
|
@@ -23,6 +25,57 @@ function buildJQL(args) {
|
|
|
23
25
|
throw new Error('Provide at least one of: query, jql, project, status, assignee, issueType');
|
|
24
26
|
return clauses.join(' AND ') + ' ORDER BY updated DESC';
|
|
25
27
|
}
|
|
28
|
+
function safeExec(cmd) {
|
|
29
|
+
try {
|
|
30
|
+
return execSync(cmd, { encoding: 'utf-8' }).trim();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parseJiraErrorDetails(errText) {
|
|
37
|
+
const trimmed = errText.trim();
|
|
38
|
+
if (!trimmed)
|
|
39
|
+
return '';
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(trimmed);
|
|
42
|
+
const parts = [];
|
|
43
|
+
if (Array.isArray(parsed.errorMessages)) {
|
|
44
|
+
for (const msg of parsed.errorMessages) {
|
|
45
|
+
const clean = (msg ?? '').trim();
|
|
46
|
+
if (clean)
|
|
47
|
+
parts.push(clean);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (parsed.errors && typeof parsed.errors === 'object') {
|
|
51
|
+
for (const [field, msg] of Object.entries(parsed.errors)) {
|
|
52
|
+
const clean = (msg ?? '').trim();
|
|
53
|
+
if (clean)
|
|
54
|
+
parts.push(`${field}: ${clean}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (parts.length > 0)
|
|
58
|
+
return parts.join(' | ');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Fallback to raw text below
|
|
62
|
+
}
|
|
63
|
+
return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
|
|
64
|
+
}
|
|
65
|
+
function formatJiraError(status, method, path, details) {
|
|
66
|
+
const prefix = `Jira ${status} ${method} ${path}`;
|
|
67
|
+
if (status === 400)
|
|
68
|
+
return `${prefix}. Invalid request or parameters. ${details}`.trim();
|
|
69
|
+
if (status === 401)
|
|
70
|
+
return `${prefix}. Authentication failed. Check JIRA_ACCESS_TOKEN.`;
|
|
71
|
+
if (status === 403)
|
|
72
|
+
return `${prefix}. Permission denied. Check Jira permissions for this token.`;
|
|
73
|
+
if (status === 404)
|
|
74
|
+
return `${prefix}. Resource not found. Verify issue/project identifiers and access.`;
|
|
75
|
+
if (status === 409)
|
|
76
|
+
return `${prefix}. Conflict. Refresh and retry. ${details}`.trim();
|
|
77
|
+
return details ? `${prefix}. ${details}` : prefix;
|
|
78
|
+
}
|
|
26
79
|
export class JiraClient {
|
|
27
80
|
baseUrl;
|
|
28
81
|
headers;
|
|
@@ -42,10 +95,33 @@ export class JiraClient {
|
|
|
42
95
|
const res = await fetch(url, opts);
|
|
43
96
|
if (!res.ok) {
|
|
44
97
|
const errText = await res.text();
|
|
45
|
-
|
|
98
|
+
const details = parseJiraErrorDetails(errText);
|
|
99
|
+
throw new Error(formatJiraError(res.status, method, path, details));
|
|
46
100
|
}
|
|
47
101
|
return res.status === 204 ? null : res.json();
|
|
48
102
|
}
|
|
103
|
+
async resolveProjectKey(projectKey) {
|
|
104
|
+
if (projectKey)
|
|
105
|
+
return projectKey;
|
|
106
|
+
const projects = (await this.request('GET', '/project?maxResults=100')) ?? [];
|
|
107
|
+
if (projects.length === 0) {
|
|
108
|
+
throw new Error('No Jira projects found for your account.');
|
|
109
|
+
}
|
|
110
|
+
const keys = new Set(projects.map((p) => p.key));
|
|
111
|
+
const branch = safeExec('git rev-parse --abbrev-ref HEAD');
|
|
112
|
+
const branchMatch = branch.match(JIRA_KEY_IN_BRANCH_RE);
|
|
113
|
+
const branchProjectKey = branchMatch?.[1];
|
|
114
|
+
if (branchProjectKey && keys.has(branchProjectKey)) {
|
|
115
|
+
return branchProjectKey;
|
|
116
|
+
}
|
|
117
|
+
if (projects.length === 1) {
|
|
118
|
+
return projects[0].key;
|
|
119
|
+
}
|
|
120
|
+
const shown = projects.slice(0, 20);
|
|
121
|
+
const lines = shown.map((p, i) => `${i + 1}. ${p.key} — ${p.name}`);
|
|
122
|
+
const extra = projects.length > shown.length ? `\n...and ${projects.length - shown.length} more.` : '';
|
|
123
|
+
throw new Error(`Please provide projectKey (or project) for this Jira action. Choose one of these project codes:\n${lines.join('\n')}${extra}`);
|
|
124
|
+
}
|
|
49
125
|
async searchIssues(args) {
|
|
50
126
|
const { maxResults = 20, startAt = 0 } = args;
|
|
51
127
|
const jql = buildJQL(args);
|
|
@@ -81,7 +157,7 @@ export class JiraClient {
|
|
|
81
157
|
return text(`${data.length} project(s):\n${lines.join('\n')}`);
|
|
82
158
|
}
|
|
83
159
|
async getIssueTypes(args) {
|
|
84
|
-
const
|
|
160
|
+
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
85
161
|
const data = await this.request('GET', `/project/${projectKey}/statuses`);
|
|
86
162
|
if (!data || data.length === 0)
|
|
87
163
|
return text('No issue types found.');
|
|
@@ -125,7 +201,7 @@ export class JiraClient {
|
|
|
125
201
|
return text(lines.join('\n'));
|
|
126
202
|
}
|
|
127
203
|
async createIssue(args) {
|
|
128
|
-
const
|
|
204
|
+
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
129
205
|
const fields = {
|
|
130
206
|
project: { key: projectKey },
|
|
131
207
|
issuetype: { name: args.issueType },
|
|
@@ -173,17 +249,26 @@ export class JiraClient {
|
|
|
173
249
|
await this.request('POST', `/issue/${args.issueKey}/comment`, { body: args.body });
|
|
174
250
|
return text(`Comment added to ${args.issueKey}.`);
|
|
175
251
|
}
|
|
176
|
-
async getTransitions(args) {
|
|
177
|
-
const data = await this.request('GET', `/issue/${args.issueKey}/transitions`);
|
|
178
|
-
if (!data || data.transitions.length === 0)
|
|
179
|
-
return text('No transitions available.');
|
|
180
|
-
const lines = data.transitions.map((t) => `${t.id}: ${t.name} → ${t.to.name}`);
|
|
181
|
-
return text(`Available transitions for ${args.issueKey}:\n${lines.join('\n')}`);
|
|
182
|
-
}
|
|
183
252
|
async transitionIssue(args) {
|
|
253
|
+
let transitionId = args.transitionId;
|
|
254
|
+
if (!transitionId) {
|
|
255
|
+
const requestedName = args.transitionName?.trim();
|
|
256
|
+
if (!requestedName) {
|
|
257
|
+
throw new Error('Provide transitionId or transitionName');
|
|
258
|
+
}
|
|
259
|
+
const data = await this.request('GET', `/issue/${args.issueKey}/transitions`);
|
|
260
|
+
const transitions = data?.transitions ?? [];
|
|
261
|
+
const lowered = requestedName.toLowerCase();
|
|
262
|
+
const match = transitions.find((t) => t.name.toLowerCase() === lowered);
|
|
263
|
+
if (!match) {
|
|
264
|
+
const available = transitions.map((t) => t.name).join(', ') || '(none)';
|
|
265
|
+
throw new Error(`Transition "${requestedName}" not found for ${args.issueKey}. Available: ${available}`);
|
|
266
|
+
}
|
|
267
|
+
transitionId = match.id;
|
|
268
|
+
}
|
|
184
269
|
await this.request('POST', `/issue/${args.issueKey}/transitions`, {
|
|
185
|
-
transition: { id:
|
|
270
|
+
transition: { id: transitionId },
|
|
186
271
|
});
|
|
187
|
-
return text(`Transitioned ${args.issueKey} using transition ${
|
|
272
|
+
return text(`Transitioned ${args.issueKey} using transition ${transitionId}.`);
|
|
188
273
|
}
|
|
189
274
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stubbedev/atlassian-mcp",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "MCP server for self-hosted Jira and Bitbucket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"prepare": "tsc",
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
|
-
"dev": "tsc --watch"
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"smoke": "npm run build && npm run smoke:tools",
|
|
21
|
+
"smoke:tools": "printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}' | node dist/index.js"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"@modelcontextprotocol/sdk": "^1.29.0",
|