@rui.branco/jira-mcp 1.7.10 → 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 +492 -149
  3. package/package.json +3 -2
  4. package/setup.js +129 -89
package/index.js CHANGED
@@ -17,6 +17,7 @@ const {
17
17
  const fs = require("fs");
18
18
  const path = require("path");
19
19
  const fetch = require("node-fetch");
20
+ const configStore = require("./config-store.js");
20
21
  const { spawn, execSync } = require("child_process");
21
22
 
22
23
  // Auto-update: check GitHub for new commits, install in background
@@ -47,11 +48,7 @@ try {
47
48
  } catch {}
48
49
 
49
50
  // Load Jira config (supports single-instance and multi-instance formats)
50
- const jiraConfigPath = path.join(
51
- process.env.HOME,
52
- ".config/jira-mcp/config.json",
53
- );
54
- const rawConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
51
+ const rawConfig = configStore.loadConfigStrict();
55
52
 
56
53
  // Confluence tool visibility: 'on-demand' (default) hides the confluence_*
57
54
  // tools until confluence_enable is called; 'always' exposes them from boot.
@@ -62,47 +59,72 @@ if (confluenceMode !== "always" && confluenceMode !== "on-demand") {
62
59
  }
63
60
  let confluenceActive = confluenceMode === "always";
64
61
 
62
+ function normalizeProjectPrefixes(projects) {
63
+ return [...new Set(
64
+ (Array.isArray(projects) ? projects : [])
65
+ .filter((project) => typeof project === "string")
66
+ .map((project) => project.trim().toUpperCase())
67
+ .filter(Boolean),
68
+ )];
69
+ }
70
+
71
+ function buildJiraInstance(inst) {
72
+ return {
73
+ ...inst,
74
+ projects: normalizeProjectPrefixes(inst.projects),
75
+ auth: Buffer.from(`${inst.email}:${inst.token}`).toString("base64"),
76
+ };
77
+ }
78
+
65
79
  // Normalize config to multi-instance format
66
80
  let instances;
67
- if (rawConfig.instances) {
81
+ if (Array.isArray(rawConfig.instances)) {
68
82
  // New multi-instance format
69
- instances = rawConfig.instances.map((inst) => ({
70
- ...inst,
71
- projects: (inst.projects || []).map((p) => p.toUpperCase()),
72
- auth: Buffer.from(`${inst.email}:${inst.token}`).toString("base64"),
73
- }));
83
+ instances = rawConfig.instances.map(buildJiraInstance);
74
84
  } else {
75
85
  // Old single-instance format — wrap as array
76
- instances = [
77
- {
78
- name: "default",
79
- email: rawConfig.email,
80
- token: rawConfig.token,
81
- baseUrl: rawConfig.baseUrl,
82
- projects: [],
83
- auth: Buffer.from(`${rawConfig.email}:${rawConfig.token}`).toString("base64"),
84
- },
85
- ];
86
+ const legacyInstance = buildJiraInstance({
87
+ name: "default",
88
+ email: rawConfig.email,
89
+ token: rawConfig.token,
90
+ baseUrl: rawConfig.baseUrl,
91
+ projects: rawConfig.projects,
92
+ });
93
+ for (const field of ["scopes", "dryRun", "defaultTeam", "projectTeams", "confluenceBaseUrl"]) {
94
+ if (rawConfig[field] !== undefined) legacyInstance[field] = rawConfig[field];
95
+ }
96
+ instances = [legacyInstance];
86
97
  }
87
98
 
88
99
  // Resolve default instance
89
- const defaultInstance =
100
+ let defaultInstance =
90
101
  (rawConfig.defaultInstance && instances.find((i) => i.name === rawConfig.defaultInstance)) ||
91
102
  instances[0];
92
103
 
93
- // Re-read config file (for persisting changes)
94
- function loadConfigFile() {
95
- try {
96
- return JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
97
- } catch {
98
- return {};
99
- }
104
+ // Instance resolution helpers
105
+ function getMappedInstancesForProject(projectPrefix) {
106
+ if (typeof projectPrefix !== "string") return [];
107
+ const upper = projectPrefix.trim().toUpperCase();
108
+ return instances.filter((i) => i.projects.includes(upper));
109
+ }
110
+
111
+ function getProjectAmbiguityError(projectPrefix, owners) {
112
+ const upper = projectPrefix.trim().toUpperCase();
113
+ const names = owners.map((owner) => `"${owner.name}"`).join(", ");
114
+ return new Error(
115
+ `Project prefix "${upper}" is mapped to multiple Jira instances (${names}). Pass an explicit instance.`,
116
+ );
117
+ }
118
+
119
+ function getMappedInstanceForProject(projectPrefix) {
120
+ const owners = getMappedInstancesForProject(projectPrefix);
121
+ return owners.length === 1 ? owners[0] : undefined;
100
122
  }
101
123
 
102
- // Instance resolution helpers
103
124
  function getInstanceForProject(projectPrefix) {
104
- const upper = projectPrefix.toUpperCase();
105
- return instances.find((i) => i.projects.includes(upper)) || defaultInstance;
125
+ const owners = getMappedInstancesForProject(projectPrefix);
126
+ if (owners.length > 1) throw getProjectAmbiguityError(projectPrefix, owners);
127
+ return owners[0] || defaultInstance;
106
128
  }
107
129
 
108
130
  function getInstanceForKey(issueKey) {
@@ -112,7 +134,9 @@ function getInstanceForKey(issueKey) {
112
134
 
113
135
  function getInstanceByName(name) {
114
136
  if (!name) return defaultInstance;
115
- return instances.find((i) => i.name === name) || defaultInstance;
137
+ const instance = instances.find((i) => i.name === name);
138
+ if (!instance) throw new Error(`Instance "${name}" not found.`);
139
+ return instance;
116
140
  }
117
141
 
118
142
  // Load Figma config (optional)
@@ -433,24 +457,285 @@ function extractTarget(args) {
433
457
  );
434
458
  }
435
459
 
460
+ const ROUTING_ISSUE_FIELDS = [
461
+ "issueKey",
462
+ "parentKey",
463
+ "inwardIssueKey",
464
+ "outwardIssueKey",
465
+ "linkedIssueKey",
466
+ ];
467
+
468
+ function isProjectPrefix(value) {
469
+ return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(value);
470
+ }
471
+
472
+ function issueKeyToProjectPrefix(value) {
473
+ if (typeof value !== "string") return null;
474
+ const match = value.trim().match(/^([A-Za-z][A-Za-z0-9_]*)-/);
475
+ return match ? match[1].toUpperCase() : null;
476
+ }
477
+
478
+ function projectValueToPrefix(value) {
479
+ if (typeof value !== "string") return null;
480
+ const trimmed = value.trim();
481
+ if (!trimmed || /^\d+$/.test(trimmed) || !isProjectPrefix(trimmed)) return null;
482
+ return trimmed.toUpperCase();
483
+ }
484
+
485
+ function extractRoutingPrefixes(toolName, args) {
486
+ if (
487
+ !args ||
488
+ typeof args !== "object" ||
489
+ TOOL_METADATA[toolName]?.product !== "jira" ||
490
+ toolName === "jira_add_instance"
491
+ ) {
492
+ return [];
493
+ }
494
+
495
+ const prefixes = [];
496
+ const add = (prefix) => {
497
+ if (prefix && !prefixes.includes(prefix)) prefixes.push(prefix);
498
+ };
499
+
500
+ for (const field of ROUTING_ISSUE_FIELDS) {
501
+ add(issueKeyToProjectPrefix(args[field]));
502
+ }
503
+ if (Array.isArray(args.issueKeys)) {
504
+ for (const issueKey of args.issueKeys) {
505
+ add(issueKeyToProjectPrefix(issueKey));
506
+ }
507
+ }
508
+ add(projectValueToPrefix(args.projectKey));
509
+ add(projectValueToPrefix(args.projectKeyOrId));
510
+ add(projectValueToPrefix(args.targetProjectKey));
511
+ return prefixes;
512
+ }
513
+
514
+ async function probeJiraProject(prefix, instance) {
515
+ let response;
516
+ try {
517
+ response = await fetch(
518
+ `${instance.baseUrl}/rest/api/3/project/${encodeURIComponent(prefix)}`,
519
+ {
520
+ method: "GET",
521
+ headers: {
522
+ Authorization: `Basic ${instance.auth}`,
523
+ Accept: "application/json",
524
+ },
525
+ },
526
+ );
527
+ } catch (error) {
528
+ throw new Error(
529
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": ${error.message}`,
530
+ );
531
+ }
532
+
533
+ if (!response || typeof response !== "object") {
534
+ throw new Error(
535
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
536
+ );
537
+ }
538
+ const status = response.status == null && response.ok ? 200 : response.status;
539
+ if (status === 200) {
540
+ if (typeof response.text !== "function") {
541
+ throw new Error(
542
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
543
+ );
544
+ }
545
+ let text;
546
+ try {
547
+ text = await response.text();
548
+ } catch {
549
+ throw new Error(
550
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
551
+ );
552
+ }
553
+ let project;
554
+ try {
555
+ project = JSON.parse(text);
556
+ } catch {
557
+ throw new Error(
558
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
559
+ );
560
+ }
561
+ if (!project || typeof project !== "object" || Array.isArray(project)) {
562
+ throw new Error(
563
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
564
+ );
565
+ }
566
+ return { kind: "match" };
567
+ }
568
+ if (status === 404) return { kind: "missing" };
569
+ if (status === 401 || status === 403) {
570
+ return { kind: "inaccessible", status };
571
+ }
572
+ if (typeof status === "number" && status >= 500) {
573
+ throw new Error(
574
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": Jira returned HTTP ${status}.`,
575
+ );
576
+ }
577
+ throw new Error(
578
+ `Project discovery failed for prefix "${prefix}" on instance "${instance.name}": unexpected HTTP status ${status}.`,
579
+ );
580
+ }
581
+
582
+ async function discoverProjectInstance(prefix) {
583
+ const results = await Promise.all(
584
+ instances.map(async (instance) => ({
585
+ instance,
586
+ result: await probeJiraProject(prefix, instance),
587
+ })),
588
+ );
589
+ const inaccessible = results.filter(({ result }) => result.kind === "inaccessible");
590
+ if (inaccessible.length > 0) {
591
+ const details = inaccessible
592
+ .map(({ instance, result }) => `"${instance.name}" (HTTP ${result.status})`)
593
+ .join(", ");
594
+ throw new Error(
595
+ `Project prefix "${prefix}" was not found on any accessible Jira instance. Permission denied on ${details}.`,
596
+ );
597
+ }
598
+
599
+ const matches = results.filter(({ result }) => result.kind === "match");
600
+ if (matches.length === 1) return matches[0].instance;
601
+ if (matches.length > 1) {
602
+ const names = matches.map(({ instance }) => `"${instance.name}"`).join(", ");
603
+ throw new Error(
604
+ `Project prefix "${prefix}" is accessible on multiple Jira instances (${names}). Pass an explicit instance.`,
605
+ );
606
+ }
607
+ throw new Error(`Project prefix "${prefix}" was not found on any configured Jira instance.`);
608
+ }
609
+
610
+ async function validateProjectOnInstance(prefix, instance) {
611
+ const result = await probeJiraProject(prefix, instance);
612
+ if (result.kind === "match") return;
613
+ if (result.kind === "inaccessible") {
614
+ throw new Error(
615
+ `Cannot access project prefix "${prefix}" on explicitly selected Jira instance "${instance.name}" (HTTP ${result.status}).`,
616
+ );
617
+ }
618
+ throw new Error(
619
+ `Project prefix "${prefix}" was not found on explicitly selected Jira instance "${instance.name}".`,
620
+ );
621
+ }
622
+
623
+ function addProjectsToConfig(config, instanceName, prefixes) {
624
+ const normalized = normalizeProjectPrefixes(prefixes);
625
+ if (Array.isArray(config.instances)) {
626
+ const savedInstance = config.instances.find((instance) => instance.name === instanceName);
627
+ if (!savedInstance) {
628
+ throw new Error(`Instance "${instanceName}" no longer exists in the Jira config.`);
629
+ }
630
+ for (const prefix of normalized) {
631
+ const otherOwners = config.instances.filter(
632
+ (instance) =>
633
+ instance.name !== instanceName &&
634
+ normalizeProjectPrefixes(instance.projects).includes(prefix),
635
+ );
636
+ if (otherOwners.length > 0) {
637
+ throw new Error(
638
+ `Project prefix "${prefix}" is already mapped to Jira instance(s) ${otherOwners.map((owner) => `"${owner.name}"`).join(", ")}.`,
639
+ );
640
+ }
641
+ }
642
+ savedInstance.projects = normalizeProjectPrefixes([
643
+ ...(savedInstance.projects || []),
644
+ ...normalized,
645
+ ]);
646
+ return savedInstance.projects;
647
+ }
648
+ if (config.instances !== undefined) {
649
+ throw new Error("Jira config has an invalid instances value.");
650
+ }
651
+ if (instanceName !== "default") {
652
+ throw new Error(`Instance "${instanceName}" no longer exists in the Jira config.`);
653
+ }
654
+ config.projects = normalizeProjectPrefixes([
655
+ ...(config.projects || []),
656
+ ...normalized,
657
+ ]);
658
+ return config.projects;
659
+ }
660
+
661
+ let discoveryWriteQueue = Promise.resolve();
662
+
663
+ function persistDiscoveredProjects(instance, prefixes) {
664
+ const normalized = normalizeProjectPrefixes(prefixes);
665
+ if (normalized.length === 0) return Promise.resolve(instance);
666
+
667
+ const operation = discoveryWriteQueue.then(() => {
668
+ const currentInstance = instances.find((candidate) => candidate.name === instance.name);
669
+ if (!currentInstance) {
670
+ throw new Error(`Instance "${instance.name}" no longer exists in memory.`);
671
+ }
672
+ const savedProjects = configStore.updateConfigSync((savedConfig) =>
673
+ addProjectsToConfig(savedConfig, instance.name, normalized),
674
+ );
675
+ // The in-memory route changes only after the atomic replacement succeeds.
676
+ currentInstance.projects = normalizeProjectPrefixes(savedProjects);
677
+ return currentInstance;
678
+ });
679
+ discoveryWriteQueue = operation.catch(() => {});
680
+ return operation;
681
+ }
682
+
683
+ function assertSameInstance(entries) {
684
+ const names = [...new Set(entries.map(({ instance }) => instance.name))];
685
+ if (names.length > 1) {
686
+ throw new Error(
687
+ `Jira targets resolve to different instances (${names.join(", ")}). Use an explicit instance and keep all keys on the same Jira instance.`,
688
+ );
689
+ }
690
+ }
691
+
436
692
  // Resolve instance for policy purposes (must run BEFORE the dispatch handler).
437
- // Routing keys vary by tool — issueKey is most common but subtask uses
438
- // parentKey, link uses inwardIssueKey, create_ticket uses projectKey, and
439
- // Confluence tools route by spaceKey. If a tool genuinely targets a specific
440
- // instance via no key (e.g. jira_get_boards), it falls back to the default
441
- // instance — the user can still override with args.instance.
442
- function resolveInstanceForTool(toolName, args) {
443
- if (!args) return defaultInstance;
444
- if (args.instance) return getInstanceByName(args.instance);
445
- const keyLike =
446
- args.issueKey ||
447
- args.parentKey ||
448
- args.inwardIssueKey ||
449
- args.outwardIssueKey;
450
- if (keyLike) return getInstanceForKey(keyLike);
451
- const projectLike = args.projectKey || args.spaceKey;
452
- if (projectLike) return getInstanceForProject(projectLike);
453
- return defaultInstance;
693
+ // Discovery happens here so scope, dry-run, rate-limit, audit, and the handler
694
+ // all use the same target instance.
695
+ async function resolveInstanceForTool(toolName, args) {
696
+ const explicit = args && args.instance ? getInstanceByName(args.instance) : null;
697
+ const prefixes = extractRoutingPrefixes(toolName, args);
698
+ if (prefixes.length === 0) return explicit || defaultInstance;
699
+
700
+ if (explicit) {
701
+ const unknown = prefixes.filter(
702
+ (prefix) => getMappedInstancesForProject(prefix).length === 0,
703
+ );
704
+ if (unknown.length > 0) {
705
+ await Promise.all(unknown.map((prefix) => validateProjectOnInstance(prefix, explicit)));
706
+ await persistDiscoveredProjects(explicit, unknown);
707
+ }
708
+ return explicit;
709
+ }
710
+
711
+ const known = [];
712
+ const unknown = [];
713
+ for (const prefix of prefixes) {
714
+ const owners = getMappedInstancesForProject(prefix);
715
+ if (owners.length === 0) {
716
+ unknown.push(prefix);
717
+ } else if (owners.length > 1) {
718
+ throw getProjectAmbiguityError(prefix, owners);
719
+ } else {
720
+ known.push({ prefix, instance: owners[0] });
721
+ }
722
+ }
723
+ assertSameInstance(known);
724
+ if (unknown.length === 0) return known[0]?.instance || defaultInstance;
725
+
726
+ const discovered = await Promise.all(
727
+ unknown.map(async (prefix) => ({
728
+ prefix,
729
+ instance: await discoverProjectInstance(prefix),
730
+ })),
731
+ );
732
+ assertSameInstance([
733
+ ...known,
734
+ ...discovered,
735
+ ]);
736
+ const selected = known[0]?.instance || discovered[0].instance;
737
+ await persistDiscoveredProjects(selected, unknown);
738
+ return selected;
454
739
  }
455
740
 
456
741
  // ============ JIRA FUNCTIONS ============
@@ -3379,7 +3664,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3379
3664
 
3380
3665
  async function runWithPolicy(name, args, handler) {
3381
3666
  const meta = TOOL_METADATA[name];
3382
- const inst = resolveInstanceForTool(name, args);
3667
+ let inst;
3668
+ try {
3669
+ inst = await resolveInstanceForTool(name, args);
3670
+ } catch (error) {
3671
+ return {
3672
+ content: [{ type: "text", text: `Error: ${error.message}` }],
3673
+ isError: true,
3674
+ };
3675
+ }
3383
3676
 
3384
3677
  // 1. Scope check
3385
3678
  const scopeResult = checkScope(name, inst);
@@ -3518,7 +3811,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3518
3811
  ],
3519
3812
  };
3520
3813
  } else if (name === "jira_search_users") {
3521
- const inst = resolveInstanceForTool(name, args);
3814
+ const inst = await resolveInstanceForTool(name, args);
3522
3815
  const maxResults = args.maxResults || 5;
3523
3816
  const hasAssignmentContext = Boolean(args.issueKey || args.projectKey);
3524
3817
  let users;
@@ -3985,7 +4278,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3985
4278
  };
3986
4279
  }
3987
4280
  if (args.issueKey) {
3988
- const result = await getChangelog(args.issueKey);
4281
+ const inst = args.instance
4282
+ ? getInstanceByName(args.instance)
4283
+ : getInstanceForKey(args.issueKey);
4284
+ const result = await getChangelog(args.issueKey, inst);
3989
4285
  return { content: [{ type: "text", text: result.formatted }] };
3990
4286
  } else {
3991
4287
  const inst = getInstanceByName(args.instance);
@@ -3995,6 +4291,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3995
4291
  } else if (name === "jira_add_instance") {
3996
4292
  const instName = args.name.trim();
3997
4293
  const existingIdx = instances.findIndex((i) => i.name === instName);
4294
+ const existing = existingIdx >= 0 ? instances[existingIdx] : {};
3998
4295
  const isUpdate = existingIdx >= 0;
3999
4296
 
4000
4297
  // For new instances, email/token/baseUrl are required
@@ -4005,27 +4302,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4005
4302
  };
4006
4303
  }
4007
4304
 
4008
- // Merge with existing or create new
4009
- const existing = isUpdate ? instances[existingIdx] : {};
4010
4305
  const email = args.email || existing.email;
4011
4306
  const token = args.token || existing.token;
4012
4307
  const baseUrl = args.baseUrl ? args.baseUrl.replace(/\/$/, "") : existing.baseUrl;
4013
- const projects = args.projects ? args.projects.map((p) => p.toUpperCase()) : (existing.projects || []);
4308
+ const requestedProjects = Array.isArray(args.projects)
4309
+ ? normalizeProjectPrefixes(args.projects)
4310
+ : null;
4311
+ const currentProjects = normalizeProjectPrefixes(existing.projects);
4014
4312
  const authStr = Buffer.from(`${email}:${token}`).toString("base64");
4015
4313
 
4016
4314
  // Resolve defaultTeam if provided
4017
- let defaultTeam = existing.defaultTeam || undefined;
4315
+ let defaultTeamChange = null;
4018
4316
  if (args.defaultTeam !== undefined) {
4019
4317
  if (args.defaultTeam === "") {
4020
- defaultTeam = undefined;
4318
+ defaultTeamChange = { value: undefined };
4021
4319
  } else if (args.defaultTeam.toLowerCase() === "none") {
4022
- defaultTeam = "none";
4320
+ defaultTeamChange = { value: "none" };
4023
4321
  } else {
4024
4322
  // Validate team exists via Jira Teams API
4025
4323
  const tempInst = { baseUrl, auth: authStr };
4026
4324
  try {
4027
4325
  const teamId = await resolveTeamId(args.defaultTeam, tempInst);
4028
- defaultTeam = { name: args.defaultTeam, id: teamId };
4326
+ defaultTeamChange = { value: { name: args.defaultTeam, id: teamId } };
4029
4327
  } catch (e) {
4030
4328
  // Try to list available teams for a helpful error
4031
4329
  try {
@@ -4046,18 +4344,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4046
4344
  }
4047
4345
 
4048
4346
  // Resolve projectTeam if provided
4049
- let projectTeams = existing.projectTeams || {};
4347
+ let projectTeamChange = null;
4050
4348
  if (args.projectKey && args.projectTeam !== undefined) {
4051
4349
  const pk = args.projectKey.toUpperCase();
4052
4350
  if (args.projectTeam === "") {
4053
- delete projectTeams[pk];
4351
+ projectTeamChange = { key: pk, value: undefined };
4054
4352
  } else if (args.projectTeam.toLowerCase() === "none") {
4055
- projectTeams[pk] = "none";
4353
+ projectTeamChange = { key: pk, value: "none" };
4056
4354
  } else {
4057
4355
  const tempInst = { baseUrl, auth: authStr };
4058
4356
  try {
4059
4357
  const teamId = await resolveTeamId(args.projectTeam, tempInst);
4060
- projectTeams[pk] = { name: args.projectTeam, id: teamId };
4358
+ projectTeamChange = {
4359
+ key: pk,
4360
+ value: { name: args.projectTeam, id: teamId },
4361
+ };
4061
4362
  } catch (e) {
4062
4363
  return {
4063
4364
  content: [{ type: "text", text: e.message }],
@@ -4067,76 +4368,106 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4067
4368
  }
4068
4369
  }
4069
4370
 
4070
- const newInstance = { name: instName, email, token, baseUrl, projects, auth: authStr, defaultTeam, projectTeams };
4371
+ const persisted = configStore.updateConfigSync((savedConfig) => {
4372
+ configStore.migrateLegacyConfig(savedConfig);
4373
+ const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
4374
+ const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
4375
+ const savedEmail = args.email || prevSaved.email || email;
4376
+ const savedToken = args.token || prevSaved.token || token;
4377
+ const savedBaseUrl = args.baseUrl
4378
+ ? args.baseUrl.replace(/\/$/, "")
4379
+ : (prevSaved.baseUrl || baseUrl);
4380
+ if (!savedEmail || !savedToken || !savedBaseUrl) {
4381
+ throw new Error("New instance requires email, token, and baseUrl.");
4382
+ }
4071
4383
 
4072
- // Update in-memory instances
4073
- if (isUpdate) {
4074
- instances[existingIdx] = newInstance;
4075
- } else {
4076
- instances.push(newInstance);
4077
- }
4078
-
4079
- // Persist to config file
4080
- const savedConfig = loadConfigFile();
4081
- if (!savedConfig.instances) {
4082
- // Migrate old format
4083
- if (savedConfig.email) {
4084
- savedConfig.instances = [{
4085
- name: "default",
4086
- email: savedConfig.email,
4087
- token: savedConfig.token,
4088
- baseUrl: savedConfig.baseUrl,
4089
- projects: [],
4090
- }];
4091
- savedConfig.defaultInstance = "default";
4092
- delete savedConfig.email;
4093
- delete savedConfig.token;
4094
- delete savedConfig.baseUrl;
4384
+ const latestProjects = normalizeProjectPrefixes(prevSaved.projects);
4385
+ const projects = requestedProjects === null
4386
+ ? latestProjects
4387
+ : normalizeProjectPrefixes([
4388
+ ...requestedProjects,
4389
+ ...latestProjects.filter((project) => !currentProjects.includes(project)),
4390
+ ]);
4391
+ configStore.assertNoDuplicateProjectOwnership(savedConfig, instName, projects);
4392
+
4393
+ const toSave = {
4394
+ ...prevSaved,
4395
+ name: instName,
4396
+ email: savedEmail,
4397
+ token: savedToken,
4398
+ baseUrl: savedBaseUrl,
4399
+ projects,
4400
+ };
4401
+ if (defaultTeamChange) {
4402
+ if (defaultTeamChange.value === undefined) {
4403
+ delete toSave.defaultTeam;
4404
+ } else {
4405
+ toSave.defaultTeam = defaultTeamChange.value;
4406
+ }
4407
+ }
4408
+ const projectTeams = { ...(prevSaved.projectTeams || {}) };
4409
+ if (projectTeamChange) {
4410
+ if (projectTeamChange.value === undefined) {
4411
+ delete projectTeams[projectTeamChange.key];
4412
+ } else {
4413
+ projectTeams[projectTeamChange.key] = projectTeamChange.value;
4414
+ }
4415
+ }
4416
+ if (Object.keys(projectTeams).length > 0) {
4417
+ toSave.projectTeams = projectTeams;
4095
4418
  } else {
4096
- savedConfig.instances = [];
4419
+ delete toSave.projectTeams;
4097
4420
  }
4098
- }
4421
+ if (savedIdx >= 0) {
4422
+ savedConfig.instances[savedIdx] = toSave;
4423
+ } else {
4424
+ savedConfig.instances.push(toSave);
4425
+ }
4426
+ if (args.setDefault || !savedConfig.defaultInstance) {
4427
+ savedConfig.defaultInstance = instName;
4428
+ }
4429
+ return {
4430
+ instance: toSave,
4431
+ isUpdate: savedIdx >= 0,
4432
+ defaultInstance: savedConfig.defaultInstance,
4433
+ };
4434
+ });
4099
4435
 
4100
- // Save without the computed auth field. Preserve any safety/observability
4101
- // fields the user has configured on the existing instance (scopes, dryRun,
4102
- // confluenceBaseUrl) so they survive a re-run of jira_add_instance.
4103
- const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
4104
- const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
4105
- const toSave = {
4106
- ...prevSaved,
4107
- name: instName,
4108
- email,
4109
- token,
4110
- baseUrl,
4111
- projects,
4112
- };
4113
- if (defaultTeam) toSave.defaultTeam = defaultTeam;
4114
- if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
4115
- if (savedIdx >= 0) {
4116
- savedConfig.instances[savedIdx] = toSave;
4436
+ // Update in-memory state only after the atomic config replacement succeeds.
4437
+ const savedInstance = buildJiraInstance(persisted.instance);
4438
+ const currentIdx = instances.findIndex((i) => i.name === instName);
4439
+ let activeInstance;
4440
+ if (currentIdx >= 0) {
4441
+ activeInstance = instances[currentIdx];
4442
+ for (const key of Object.keys(activeInstance)) delete activeInstance[key];
4443
+ Object.assign(activeInstance, savedInstance);
4117
4444
  } else {
4118
- savedConfig.instances.push(toSave);
4119
- }
4120
- if (args.setDefault || !savedConfig.defaultInstance) {
4121
- savedConfig.defaultInstance = instName;
4122
- }
4123
- fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
4124
-
4125
- const action = isUpdate ? "Updated" : "Added";
4126
- let text = `${action} instance "${instName}" (${baseUrl}).`;
4127
- if (projects.length > 0) text += ` Projects: ${projects.join(", ")}.`;
4445
+ instances.push(savedInstance);
4446
+ activeInstance = savedInstance;
4447
+ }
4448
+ defaultInstance =
4449
+ instances.find((instance) => instance.name === persisted.defaultInstance) ||
4450
+ instances[0];
4451
+ rawConfig.defaultInstance = persisted.defaultInstance;
4452
+
4453
+ const action = persisted.isUpdate ? "Updated" : "Added";
4454
+ const savedBaseUrl = activeInstance.baseUrl;
4455
+ const savedProjects = activeInstance.projects;
4456
+ const projectTeams = activeInstance.projectTeams || {};
4457
+ let text = `${action} instance "${instName}" (${savedBaseUrl}).`;
4458
+ if (savedProjects.length > 0) text += ` Projects: ${savedProjects.join(", ")}.`;
4128
4459
  if (args.setDefault) text += " Set as default.";
4129
- if (args.projectKey && args.projectTeam !== undefined) {
4130
- const pk = args.projectKey.toUpperCase();
4131
- const pt = projectTeams[pk];
4460
+ if (projectTeamChange) {
4461
+ const pt = projectTeams[projectTeamChange.key];
4132
4462
  if (pt === "none") {
4133
- text += ` Project ${pk} team: None (disabled).`;
4463
+ text += ` Project ${projectTeamChange.key} team: None (disabled).`;
4134
4464
  } else if (pt) {
4135
- text += ` Project ${pk} team: ${pt.name}.`;
4465
+ text += ` Project ${projectTeamChange.key} team: ${pt.name}.`;
4136
4466
  } else {
4137
- text += ` Project ${pk} team override removed.`;
4467
+ text += ` Project ${projectTeamChange.key} team override removed.`;
4138
4468
  }
4139
4469
  }
4470
+ const defaultTeam = activeInstance.defaultTeam;
4140
4471
  if (defaultTeam === "none") {
4141
4472
  text += " Default team: None (disabled).";
4142
4473
  } else if (defaultTeam) {
@@ -4144,8 +4475,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4144
4475
  } else {
4145
4476
  // No default team — fetch available teams and prompt
4146
4477
  try {
4147
- const tempInst = { baseUrl, auth: authStr };
4148
- const teams = await listTeams(tempInst);
4478
+ const teams = await listTeams(activeInstance);
4149
4479
  if (teams && teams.length > 0) {
4150
4480
  const list = teams.map((t, i) => `${i + 1}. ${t.title}`).join("\n");
4151
4481
  text += `\n\n⚠ No default team configured. Available teams:\n0. None\n${list}\n\nIMPORTANT: You MUST display this full numbered list in your chat response so the user can see and choose. NEVER auto-select a team. Ask the user to pick a number, type a team name to search, or choose None.\nIf they pick one, call jira_add_instance with name="${instName}" and defaultTeam="<team name>".\nIf the team is not listed, the user can type a name and you search with jira_add_instance defaultTeam="<name>" (it validates automatically).\nIf "None" is selected, call jira_add_instance with defaultTeam="none" to stop future prompts.`;
@@ -4159,33 +4489,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4159
4489
 
4160
4490
  } else if (name === "jira_remove_instance") {
4161
4491
  const instName = args.name.trim();
4162
-
4163
- if (instances.length <= 1) {
4492
+ let removal;
4493
+ try {
4494
+ removal = configStore.updateConfigSync((savedConfig) => {
4495
+ if (!Array.isArray(savedConfig.instances)) {
4496
+ throw new Error(`Instance "${instName}" not found.`);
4497
+ }
4498
+ if (savedConfig.instances.length <= 1) {
4499
+ throw new Error("Cannot remove the last remaining instance.");
4500
+ }
4501
+ const idx = savedConfig.instances.findIndex((i) => i.name === instName);
4502
+ if (idx < 0) {
4503
+ throw new Error(`Instance "${instName}" not found.`);
4504
+ }
4505
+ savedConfig.instances.splice(idx, 1);
4506
+ if (savedConfig.defaultInstance === instName) {
4507
+ savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
4508
+ }
4509
+ return { defaultInstance: savedConfig.defaultInstance };
4510
+ });
4511
+ } catch (error) {
4164
4512
  return {
4165
- content: [{ type: "text", text: "Cannot remove the last remaining instance." }],
4513
+ content: [{ type: "text", text: error.message }],
4166
4514
  isError: true,
4167
4515
  };
4168
4516
  }
4169
4517
 
4518
+ // Update in-memory state only after the atomic config replacement succeeds.
4170
4519
  const idx = instances.findIndex((i) => i.name === instName);
4171
- if (idx < 0) {
4172
- return {
4173
- content: [{ type: "text", text: `Instance "${instName}" not found.` }],
4174
- isError: true,
4175
- };
4176
- }
4177
-
4178
- instances.splice(idx, 1);
4179
-
4180
- // Persist to config file
4181
- const savedConfig = loadConfigFile();
4182
- if (savedConfig.instances) {
4183
- savedConfig.instances = savedConfig.instances.filter((i) => i.name !== instName);
4184
- if (savedConfig.defaultInstance === instName) {
4185
- savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
4186
- }
4187
- fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
4188
- }
4520
+ if (idx >= 0) instances.splice(idx, 1);
4521
+ defaultInstance =
4522
+ instances.find((instance) => instance.name === removal.defaultInstance) ||
4523
+ instances[0];
4524
+ rawConfig.defaultInstance = removal.defaultInstance;
4189
4525
 
4190
4526
  return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
4191
4527
 
@@ -4758,7 +5094,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4758
5094
  };
4759
5095
 
4760
5096
  } else if (name === "jira_move_to_sprint") {
4761
- const inst = getInstanceByName(args.instance);
5097
+ const inst = args.instance
5098
+ ? getInstanceByName(args.instance)
5099
+ : (args.issueKeys?.length ? getInstanceForKey(args.issueKeys[0]) : defaultInstance);
4762
5100
  await fetchJiraAgile(
4763
5101
  `/sprint/${args.sprintId}/issue`,
4764
5102
  { method: "POST", body: { issues: args.issueKeys } },
@@ -4793,7 +5131,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4793
5131
  return { content: [{ type: "text", text }] };
4794
5132
 
4795
5133
  } else if (name === "jira_get_boards") {
4796
- const inst = getInstanceByName(args.instance);
5134
+ const inst = args.instance
5135
+ ? getInstanceByName(args.instance)
5136
+ : (projectValueToPrefix(args.projectKeyOrId)
5137
+ ? getInstanceForProject(args.projectKeyOrId)
5138
+ : defaultInstance);
4797
5139
  let endpoint = "/board?maxResults=50";
4798
5140
  if (args.projectKeyOrId) {
4799
5141
  endpoint += `&projectKeyOrId=${encodeURIComponent(args.projectKeyOrId)}`;
@@ -5414,7 +5756,8 @@ if (typeof module !== "undefined") {
5414
5756
  // Policy module exports
5415
5757
  TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
5416
5758
  checkRateLimit, _setClockForTests, _resetBucketsForTests,
5417
- resolveInstanceForTool, _injectDryRunSchema,
5759
+ extractRoutingPrefixes, probeJiraProject, discoverProjectInstance,
5760
+ persistDiscoveredProjects, resolveInstanceForTool, _injectDryRunSchema,
5418
5761
  AUDIT_LOG_PATH,
5419
5762
  };
5420
5763
  }