@stubbedev/atlassian-mcp 0.0.5 → 0.1.1

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/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
- throw new Error(`Jira ${res.status} ${method} ${path}: ${errText}`);
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 { projectKey } = args;
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 { projectKey } = args;
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: args.transitionId },
270
+ transition: { id: transitionId },
186
271
  });
187
- return text(`Transitioned ${args.issueKey} using transition ${args.transitionId}.`);
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.5",
3
+ "version": "0.1.1",
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",