@rui.branco/jira-mcp 1.7.9 → 1.7.11

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.
Files changed (4) hide show
  1. package/config-store.js +553 -0
  2. package/index.js +544 -156
  3. package/package.json +3 -2
  4. package/setup.js +129 -89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rui.branco/jira-mcp",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "description": "Jira & Confluence MCP server for any MCP-compatible AI client (Claude Code, Codex, Antigravity, Cursor, etc.) - fetch tickets, search with JQL, update tickets, manage comments, change status, get Figma designs. Optional per-tool scopes, dry-run, audit log, and rate limiting.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
- "setup.js"
11
+ "setup.js",
12
+ "config-store.js"
12
13
  ],
13
14
  "scripts": {
14
15
  "setup": "node setup.js",
package/setup.js CHANGED
@@ -3,9 +3,9 @@
3
3
  const readline = require("readline");
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
+ const configStore = require("./config-store.js");
6
7
 
7
- const configDir = path.join(process.env.HOME, ".config/jira-mcp");
8
- const configPath = path.join(configDir, "config.json");
8
+ const configPath = configStore.configPath;
9
9
 
10
10
  // Check for command line arguments
11
11
  let args = process.argv.slice(2);
@@ -14,62 +14,55 @@ if (args[0] === "setup") args = args.slice(1);
14
14
 
15
15
  // Load existing config if present
16
16
  function loadConfig() {
17
- try {
18
- if (fs.existsSync(configPath)) {
19
- return JSON.parse(fs.readFileSync(configPath, "utf8"));
20
- }
21
- } catch {}
22
- return null;
17
+ return configStore.loadConfig();
23
18
  }
24
19
 
25
- function saveConfig(config) {
26
- if (!fs.existsSync(configDir)) {
27
- fs.mkdirSync(configDir, { recursive: true });
28
- }
29
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
20
+ function saveConfig(mutator) {
21
+ return configStore.updateConfigSync(mutator);
30
22
  }
31
23
 
32
24
  // Non-interactive: setup add <name> <email> <token> <baseUrl> <projects>
33
25
  if (args[0] === "add" && args.length >= 5) {
34
26
  const [, name, email, token, baseUrl, ...projectArgs] = args;
35
27
  const projects = projectArgs.length > 0
36
- ? projectArgs.join(",").split(",").map((p) => p.trim().toUpperCase()).filter(Boolean)
28
+ ? configStore.normalizeProjects(projectArgs.join(",").split(","))
37
29
  : [];
38
30
 
39
- const config = loadConfig() || {};
40
-
41
- // Migrate old format if needed
42
- if (config.email && !config.instances) {
43
- config.instances = [{
44
- name: "default",
45
- email: config.email,
46
- token: config.token,
47
- baseUrl: config.baseUrl,
48
- projects: [],
49
- }];
50
- config.defaultInstance = "default";
51
- delete config.email;
52
- delete config.token;
53
- delete config.baseUrl;
54
- }
55
-
56
- if (!config.instances) config.instances = [];
57
-
58
- // Replace or add instance
59
- const existing = config.instances.findIndex((i) => i.name === name);
60
- const instance = { name, email, token, baseUrl: baseUrl.replace(/\/$/, ""), projects };
61
- if (existing >= 0) {
62
- config.instances[existing] = instance;
63
- } else {
64
- config.instances.push(instance);
31
+ let saved;
32
+ try {
33
+ saved = saveConfig((config) => {
34
+ configStore.migrateLegacyConfig(config);
35
+ const existingIdx = config.instances.findIndex((instance) => instance.name === name);
36
+ const previous = existingIdx >= 0 ? config.instances[existingIdx] : {};
37
+ const mergedProjects = configStore.normalizeProjects([
38
+ ...(previous.projects || []),
39
+ ...projects,
40
+ ]);
41
+ configStore.assertNoDuplicateProjectOwnership(config, name, mergedProjects);
42
+ const instance = {
43
+ ...previous,
44
+ name,
45
+ email,
46
+ token,
47
+ baseUrl: baseUrl.replace(/\/$/, ""),
48
+ projects: mergedProjects,
49
+ };
50
+ if (existingIdx >= 0) {
51
+ config.instances[existingIdx] = instance;
52
+ } else {
53
+ config.instances.push(instance);
54
+ }
55
+ if (!config.defaultInstance) config.defaultInstance = name;
56
+ return { instance };
57
+ });
58
+ } catch (error) {
59
+ console.error("Setup failed:", error.message);
60
+ process.exit(1);
65
61
  }
66
62
 
67
- if (!config.defaultInstance) config.defaultInstance = name;
68
-
69
- saveConfig(config);
70
63
  console.log(`Instance "${name}" saved to ${configPath}`);
71
- if (projects.length > 0) {
72
- console.log(`Projects: ${projects.join(", ")}`);
64
+ if (saved.instance.projects.length > 0) {
65
+ console.log(`Projects: ${saved.instance.projects.join(", ")}`);
73
66
  }
74
67
  process.exit(0);
75
68
  }
@@ -77,19 +70,23 @@ if (args[0] === "add" && args.length >= 5) {
77
70
  // Non-interactive: setup remove <name>
78
71
  if (args[0] === "remove" && args.length >= 2) {
79
72
  const name = args[1];
80
- const config = loadConfig();
81
-
82
- if (!config || !config.instances) {
83
- console.error("No multi-instance config found.");
73
+ let removed;
74
+ try {
75
+ removed = saveConfig((config) => {
76
+ if (!Array.isArray(config.instances)) {
77
+ throw new Error("No multi-instance config found.");
78
+ }
79
+ config.instances = config.instances.filter((instance) => instance.name !== name);
80
+ if (config.defaultInstance === name) {
81
+ config.defaultInstance = config.instances[0]?.name || null;
82
+ }
83
+ return { defaultInstance: config.defaultInstance };
84
+ });
85
+ } catch (error) {
86
+ console.error(error.message);
84
87
  process.exit(1);
85
88
  }
86
89
 
87
- config.instances = config.instances.filter((i) => i.name !== name);
88
- if (config.defaultInstance === name) {
89
- config.defaultInstance = config.instances[0]?.name || null;
90
- }
91
-
92
- saveConfig(config);
93
90
  console.log(`Instance "${name}" removed.`);
94
91
  process.exit(0);
95
92
  }
@@ -97,7 +94,30 @@ if (args[0] === "remove" && args.length >= 2) {
97
94
  // Non-interactive: setup <email> <token> <baseUrl> (legacy single-instance)
98
95
  if (args.length >= 3 && args[0] !== "add" && args[0] !== "remove") {
99
96
  const [email, token, baseUrl] = args;
100
- saveConfig({ email, token, baseUrl: baseUrl.replace(/\/$/, "") });
97
+ try {
98
+ saveConfig((config) => {
99
+ const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
100
+ if (Array.isArray(config.instances)) {
101
+ const instance =
102
+ config.instances.find((candidate) => candidate.name === config.defaultInstance) ||
103
+ config.instances[0];
104
+ if (!instance) throw new Error("No Jira instances are configured.");
105
+ instance.email = email;
106
+ instance.token = token;
107
+ instance.baseUrl = normalizedBaseUrl;
108
+ } else {
109
+ if (config.instances !== undefined) {
110
+ throw new Error("Jira config has an invalid instances value.");
111
+ }
112
+ config.email = email;
113
+ config.token = token;
114
+ config.baseUrl = normalizedBaseUrl;
115
+ }
116
+ });
117
+ } catch (error) {
118
+ console.error("Setup failed:", error.message);
119
+ process.exit(1);
120
+ }
101
121
  console.log(`Config saved to ${configPath}`);
102
122
  process.exit(0);
103
123
  }
@@ -166,7 +186,10 @@ async function setup() {
166
186
  const team = await pickDefaultTeam(baseUrl.replace(/\/$/, ""), authStr);
167
187
  if (team) config.defaultTeam = team;
168
188
 
169
- saveConfig(config);
189
+ saveConfig((latest) => {
190
+ for (const key of Object.keys(latest)) delete latest[key];
191
+ Object.assign(latest, config);
192
+ });
170
193
  console.log(`\nConfig saved to ${configPath}`);
171
194
 
172
195
  printFigmaStatus();
@@ -244,8 +267,12 @@ async function setInstanceTeam(config) {
244
267
  const authStr = Buffer.from(`${target.email}:${target.token}`).toString("base64");
245
268
  const team = await pickDefaultTeam(target.baseUrl, authStr);
246
269
  if (team) {
247
- target.defaultTeam = team;
248
- saveConfig(config);
270
+ const targetName = target.name;
271
+ saveConfig((latest) => {
272
+ const current = latest.instances?.find((instance) => instance.name === targetName);
273
+ if (!current) throw new Error(`Instance "${targetName}" no longer exists.`);
274
+ current.defaultTeam = team;
275
+ });
249
276
  console.log(`\nSaved default team "${team.name}" for instance "${target.name}".`);
250
277
  } else {
251
278
  console.log("No changes made.");
@@ -254,24 +281,15 @@ async function setInstanceTeam(config) {
254
281
  }
255
282
 
256
283
  async function addInstance(config) {
257
- // Migrate old format if needed
284
+ let legacyName = null;
285
+ let legacyProjects = [];
258
286
  if (config.email && !config.instances) {
259
287
  const oldName = await ask("Name for your existing instance (e.g., work): ");
260
288
  const oldProjects = await ask("Project prefixes for existing instance (comma-separated, e.g., MODS,ENG): ");
261
- config = {
262
- instances: [{
263
- name: oldName.trim() || "default",
264
- email: config.email,
265
- token: config.token,
266
- baseUrl: config.baseUrl,
267
- projects: oldProjects.split(",").map((p) => p.trim().toUpperCase()).filter(Boolean),
268
- }],
269
- defaultInstance: oldName.trim() || "default",
270
- };
289
+ legacyName = oldName.trim() || "default";
290
+ legacyProjects = configStore.normalizeProjects(oldProjects.split(","));
271
291
  }
272
292
 
273
- if (!config.instances) config.instances = [];
274
-
275
293
  console.log("\n--- Add New Instance ---\n");
276
294
  console.log("To get your Jira API token:");
277
295
  console.log("1. Go to https://id.atlassian.com/manage-profile/security/api-tokens");
@@ -283,7 +301,7 @@ async function addInstance(config) {
283
301
  const token = await ask("Jira API token: ");
284
302
  const baseUrl = await ask("Jira base URL (e.g., https://company.atlassian.net): ");
285
303
  const projectsInput = await ask("Project prefixes (comma-separated, e.g., SIDE,FUN): ");
286
- const projects = projectsInput.split(",").map((p) => p.trim().toUpperCase()).filter(Boolean);
304
+ const projects = configStore.normalizeProjects(projectsInput.split(","));
287
305
 
288
306
  const trimmedBaseUrl = baseUrl.trim().replace(/\/$/, "");
289
307
  const authStr = Buffer.from(`${email.trim()}:${token.trim()}`).toString("base64");
@@ -298,27 +316,49 @@ async function addInstance(config) {
298
316
  };
299
317
  if (team) instance.defaultTeam = team;
300
318
 
301
- // Replace or add
302
- const idx = config.instances.findIndex((i) => i.name === instance.name);
303
- if (idx >= 0) {
304
- config.instances[idx] = instance;
305
- } else {
306
- config.instances.push(instance);
307
- }
308
-
309
- if (!config.defaultInstance) config.defaultInstance = instance.name;
310
-
311
319
  const setDefault = await ask(`Set "${instance.name}" as default? (y/N): `);
312
- if (setDefault.trim().toLowerCase() === "y") {
313
- config.defaultInstance = instance.name;
314
- }
320
+ const makeDefault = setDefault.trim().toLowerCase() === "y";
321
+
322
+ const savedConfig = saveConfig((latest) => {
323
+ if (legacyName && !Array.isArray(latest.instances)) {
324
+ configStore.migrateLegacyConfig(latest, legacyName);
325
+ const migrated = latest.instances.find((item) => item.name === legacyName);
326
+ migrated.projects = configStore.normalizeProjects([
327
+ ...(migrated.projects || []),
328
+ ...legacyProjects,
329
+ ]);
330
+ } else {
331
+ configStore.migrateLegacyConfig(latest);
332
+ }
333
+
334
+ const idx = latest.instances.findIndex((item) => item.name === instance.name);
335
+ const previous = idx >= 0 ? latest.instances[idx] : {};
336
+ const savedProjects = configStore.normalizeProjects([
337
+ ...(previous.projects || []),
338
+ ...instance.projects,
339
+ ]);
340
+ configStore.assertNoDuplicateProjectOwnership(latest, instance.name, savedProjects);
341
+ const toSave = {
342
+ ...previous,
343
+ ...instance,
344
+ projects: savedProjects,
345
+ };
346
+ if (idx >= 0) {
347
+ latest.instances[idx] = toSave;
348
+ } else {
349
+ latest.instances.push(toSave);
350
+ }
351
+ if (!latest.defaultInstance || makeDefault) {
352
+ latest.defaultInstance = instance.name;
353
+ }
354
+ return latest;
355
+ });
315
356
 
316
- saveConfig(config);
317
357
  console.log(`\nInstance "${instance.name}" saved to ${configPath}`);
318
358
 
319
359
  console.log("\nAll instances:");
320
- for (const inst of config.instances) {
321
- const isDefault = inst.name === config.defaultInstance ? " (default)" : "";
360
+ for (const inst of savedConfig.instances) {
361
+ const isDefault = inst.name === savedConfig.defaultInstance ? " (default)" : "";
322
362
  const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
323
363
  console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl}${projs}`);
324
364
  }