@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/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 ============
@@ -474,9 +759,12 @@ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
474
759
  });
475
760
  if (!response.ok) {
476
761
  const errorBody = await response.text().catch(() => "");
477
- throw new Error(
762
+ const error = new Error(
478
763
  `Jira API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ""}`,
479
764
  );
765
+ error.status = response.status;
766
+ error.body = errorBody;
767
+ throw error;
480
768
  }
481
769
  const text = await response.text();
482
770
  return text ? JSON.parse(text) : {};
@@ -2351,7 +2639,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2351
2639
  {
2352
2640
  name: "jira_search_users",
2353
2641
  description:
2354
- "Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
2642
+ "Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments. For assignment-oriented searches, provide issueKey or projectKey to find users assignable to that issue or project when global user browsing is unavailable. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
2355
2643
  inputSchema: {
2356
2644
  type: "object",
2357
2645
  properties: {
@@ -2364,6 +2652,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2364
2652
  type: "number",
2365
2653
  description: "Max results (default 5)",
2366
2654
  },
2655
+ issueKey: {
2656
+ type: "string",
2657
+ description:
2658
+ "Optional issue key for assignment-oriented searches (e.g., 'MODS-123'). If global user search is empty or user browsing is forbidden, search users assignable to this issue. The instance is auto-detected from the issue key when omitted.",
2659
+ },
2660
+ projectKey: {
2661
+ type: "string",
2662
+ description:
2663
+ "Optional project key for assignment-oriented searches (e.g., 'MODS'). If global user search is empty or user browsing is forbidden, search users assignable to this project. Used when issueKey is not provided.",
2664
+ },
2367
2665
  instance: {
2368
2666
  type: "string",
2369
2667
  description: "Instance name (optional).",
@@ -3366,7 +3664,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3366
3664
 
3367
3665
  async function runWithPolicy(name, args, handler) {
3368
3666
  const meta = TOOL_METADATA[name];
3369
- 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
+ }
3370
3676
 
3371
3677
  // 1. Scope check
3372
3678
  const scopeResult = checkScope(name, inst);
@@ -3505,13 +3811,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3505
3811
  ],
3506
3812
  };
3507
3813
  } else if (name === "jira_search_users") {
3508
- const inst = getInstanceByName(args.instance);
3814
+ const inst = await resolveInstanceForTool(name, args);
3509
3815
  const maxResults = args.maxResults || 5;
3510
- const users = await fetchJira(
3511
- `/user/search?query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
3512
- {},
3513
- inst,
3514
- );
3816
+ const hasAssignmentContext = Boolean(args.issueKey || args.projectKey);
3817
+ let users;
3818
+ let useAssignableFallback = false;
3819
+
3820
+ try {
3821
+ users = await fetchJira(
3822
+ `/user/search?query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
3823
+ {},
3824
+ inst,
3825
+ );
3826
+ } catch (error) {
3827
+ const errorDetail = String(error.body || error.message || "").toLowerCase();
3828
+ if (
3829
+ hasAssignmentContext &&
3830
+ error.status === 403 &&
3831
+ /(browse users|user browsing|users and groups)/.test(errorDetail)
3832
+ ) {
3833
+ useAssignableFallback = true;
3834
+ } else {
3835
+ throw error;
3836
+ }
3837
+ }
3838
+
3839
+ if (
3840
+ useAssignableFallback ||
3841
+ (hasAssignmentContext && (!users || users.length === 0))
3842
+ ) {
3843
+ const scope = args.issueKey
3844
+ ? `issueKey=${encodeURIComponent(args.issueKey)}`
3845
+ : `project=${encodeURIComponent(args.projectKey)}`;
3846
+ users = await fetchJira(
3847
+ `/user/assignable/search?${scope}&query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
3848
+ {},
3849
+ inst,
3850
+ );
3851
+ }
3852
+
3515
3853
  if (!users || users.length === 0) {
3516
3854
  return {
3517
3855
  content: [
@@ -3940,7 +4278,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3940
4278
  };
3941
4279
  }
3942
4280
  if (args.issueKey) {
3943
- 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);
3944
4285
  return { content: [{ type: "text", text: result.formatted }] };
3945
4286
  } else {
3946
4287
  const inst = getInstanceByName(args.instance);
@@ -3950,6 +4291,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3950
4291
  } else if (name === "jira_add_instance") {
3951
4292
  const instName = args.name.trim();
3952
4293
  const existingIdx = instances.findIndex((i) => i.name === instName);
4294
+ const existing = existingIdx >= 0 ? instances[existingIdx] : {};
3953
4295
  const isUpdate = existingIdx >= 0;
3954
4296
 
3955
4297
  // For new instances, email/token/baseUrl are required
@@ -3960,27 +4302,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3960
4302
  };
3961
4303
  }
3962
4304
 
3963
- // Merge with existing or create new
3964
- const existing = isUpdate ? instances[existingIdx] : {};
3965
4305
  const email = args.email || existing.email;
3966
4306
  const token = args.token || existing.token;
3967
4307
  const baseUrl = args.baseUrl ? args.baseUrl.replace(/\/$/, "") : existing.baseUrl;
3968
- 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);
3969
4312
  const authStr = Buffer.from(`${email}:${token}`).toString("base64");
3970
4313
 
3971
4314
  // Resolve defaultTeam if provided
3972
- let defaultTeam = existing.defaultTeam || undefined;
4315
+ let defaultTeamChange = null;
3973
4316
  if (args.defaultTeam !== undefined) {
3974
4317
  if (args.defaultTeam === "") {
3975
- defaultTeam = undefined;
4318
+ defaultTeamChange = { value: undefined };
3976
4319
  } else if (args.defaultTeam.toLowerCase() === "none") {
3977
- defaultTeam = "none";
4320
+ defaultTeamChange = { value: "none" };
3978
4321
  } else {
3979
4322
  // Validate team exists via Jira Teams API
3980
4323
  const tempInst = { baseUrl, auth: authStr };
3981
4324
  try {
3982
4325
  const teamId = await resolveTeamId(args.defaultTeam, tempInst);
3983
- defaultTeam = { name: args.defaultTeam, id: teamId };
4326
+ defaultTeamChange = { value: { name: args.defaultTeam, id: teamId } };
3984
4327
  } catch (e) {
3985
4328
  // Try to list available teams for a helpful error
3986
4329
  try {
@@ -4001,18 +4344,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4001
4344
  }
4002
4345
 
4003
4346
  // Resolve projectTeam if provided
4004
- let projectTeams = existing.projectTeams || {};
4347
+ let projectTeamChange = null;
4005
4348
  if (args.projectKey && args.projectTeam !== undefined) {
4006
4349
  const pk = args.projectKey.toUpperCase();
4007
4350
  if (args.projectTeam === "") {
4008
- delete projectTeams[pk];
4351
+ projectTeamChange = { key: pk, value: undefined };
4009
4352
  } else if (args.projectTeam.toLowerCase() === "none") {
4010
- projectTeams[pk] = "none";
4353
+ projectTeamChange = { key: pk, value: "none" };
4011
4354
  } else {
4012
4355
  const tempInst = { baseUrl, auth: authStr };
4013
4356
  try {
4014
4357
  const teamId = await resolveTeamId(args.projectTeam, tempInst);
4015
- projectTeams[pk] = { name: args.projectTeam, id: teamId };
4358
+ projectTeamChange = {
4359
+ key: pk,
4360
+ value: { name: args.projectTeam, id: teamId },
4361
+ };
4016
4362
  } catch (e) {
4017
4363
  return {
4018
4364
  content: [{ type: "text", text: e.message }],
@@ -4022,76 +4368,106 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4022
4368
  }
4023
4369
  }
4024
4370
 
4025
- 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
+ }
4026
4383
 
4027
- // Update in-memory instances
4028
- if (isUpdate) {
4029
- instances[existingIdx] = newInstance;
4030
- } else {
4031
- instances.push(newInstance);
4032
- }
4033
-
4034
- // Persist to config file
4035
- const savedConfig = loadConfigFile();
4036
- if (!savedConfig.instances) {
4037
- // Migrate old format
4038
- if (savedConfig.email) {
4039
- savedConfig.instances = [{
4040
- name: "default",
4041
- email: savedConfig.email,
4042
- token: savedConfig.token,
4043
- baseUrl: savedConfig.baseUrl,
4044
- projects: [],
4045
- }];
4046
- savedConfig.defaultInstance = "default";
4047
- delete savedConfig.email;
4048
- delete savedConfig.token;
4049
- 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;
4050
4418
  } else {
4051
- savedConfig.instances = [];
4419
+ delete toSave.projectTeams;
4052
4420
  }
4053
- }
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
+ });
4054
4435
 
4055
- // Save without the computed auth field. Preserve any safety/observability
4056
- // fields the user has configured on the existing instance (scopes, dryRun,
4057
- // confluenceBaseUrl) so they survive a re-run of jira_add_instance.
4058
- const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
4059
- const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
4060
- const toSave = {
4061
- ...prevSaved,
4062
- name: instName,
4063
- email,
4064
- token,
4065
- baseUrl,
4066
- projects,
4067
- };
4068
- if (defaultTeam) toSave.defaultTeam = defaultTeam;
4069
- if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
4070
- if (savedIdx >= 0) {
4071
- 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);
4072
4444
  } else {
4073
- savedConfig.instances.push(toSave);
4074
- }
4075
- if (args.setDefault || !savedConfig.defaultInstance) {
4076
- savedConfig.defaultInstance = instName;
4077
- }
4078
- fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
4079
-
4080
- const action = isUpdate ? "Updated" : "Added";
4081
- let text = `${action} instance "${instName}" (${baseUrl}).`;
4082
- 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(", ")}.`;
4083
4459
  if (args.setDefault) text += " Set as default.";
4084
- if (args.projectKey && args.projectTeam !== undefined) {
4085
- const pk = args.projectKey.toUpperCase();
4086
- const pt = projectTeams[pk];
4460
+ if (projectTeamChange) {
4461
+ const pt = projectTeams[projectTeamChange.key];
4087
4462
  if (pt === "none") {
4088
- text += ` Project ${pk} team: None (disabled).`;
4463
+ text += ` Project ${projectTeamChange.key} team: None (disabled).`;
4089
4464
  } else if (pt) {
4090
- text += ` Project ${pk} team: ${pt.name}.`;
4465
+ text += ` Project ${projectTeamChange.key} team: ${pt.name}.`;
4091
4466
  } else {
4092
- text += ` Project ${pk} team override removed.`;
4467
+ text += ` Project ${projectTeamChange.key} team override removed.`;
4093
4468
  }
4094
4469
  }
4470
+ const defaultTeam = activeInstance.defaultTeam;
4095
4471
  if (defaultTeam === "none") {
4096
4472
  text += " Default team: None (disabled).";
4097
4473
  } else if (defaultTeam) {
@@ -4099,8 +4475,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4099
4475
  } else {
4100
4476
  // No default team — fetch available teams and prompt
4101
4477
  try {
4102
- const tempInst = { baseUrl, auth: authStr };
4103
- const teams = await listTeams(tempInst);
4478
+ const teams = await listTeams(activeInstance);
4104
4479
  if (teams && teams.length > 0) {
4105
4480
  const list = teams.map((t, i) => `${i + 1}. ${t.title}`).join("\n");
4106
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.`;
@@ -4114,33 +4489,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4114
4489
 
4115
4490
  } else if (name === "jira_remove_instance") {
4116
4491
  const instName = args.name.trim();
4117
-
4118
- 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) {
4119
4512
  return {
4120
- content: [{ type: "text", text: "Cannot remove the last remaining instance." }],
4513
+ content: [{ type: "text", text: error.message }],
4121
4514
  isError: true,
4122
4515
  };
4123
4516
  }
4124
4517
 
4518
+ // Update in-memory state only after the atomic config replacement succeeds.
4125
4519
  const idx = instances.findIndex((i) => i.name === instName);
4126
- if (idx < 0) {
4127
- return {
4128
- content: [{ type: "text", text: `Instance "${instName}" not found.` }],
4129
- isError: true,
4130
- };
4131
- }
4132
-
4133
- instances.splice(idx, 1);
4134
-
4135
- // Persist to config file
4136
- const savedConfig = loadConfigFile();
4137
- if (savedConfig.instances) {
4138
- savedConfig.instances = savedConfig.instances.filter((i) => i.name !== instName);
4139
- if (savedConfig.defaultInstance === instName) {
4140
- savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
4141
- }
4142
- fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
4143
- }
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;
4144
4525
 
4145
4526
  return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
4146
4527
 
@@ -4713,7 +5094,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4713
5094
  };
4714
5095
 
4715
5096
  } else if (name === "jira_move_to_sprint") {
4716
- const inst = getInstanceByName(args.instance);
5097
+ const inst = args.instance
5098
+ ? getInstanceByName(args.instance)
5099
+ : (args.issueKeys?.length ? getInstanceForKey(args.issueKeys[0]) : defaultInstance);
4717
5100
  await fetchJiraAgile(
4718
5101
  `/sprint/${args.sprintId}/issue`,
4719
5102
  { method: "POST", body: { issues: args.issueKeys } },
@@ -4748,7 +5131,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4748
5131
  return { content: [{ type: "text", text }] };
4749
5132
 
4750
5133
  } else if (name === "jira_get_boards") {
4751
- const inst = getInstanceByName(args.instance);
5134
+ const inst = args.instance
5135
+ ? getInstanceByName(args.instance)
5136
+ : (projectValueToPrefix(args.projectKeyOrId)
5137
+ ? getInstanceForProject(args.projectKeyOrId)
5138
+ : defaultInstance);
4752
5139
  let endpoint = "/board?maxResults=50";
4753
5140
  if (args.projectKeyOrId) {
4754
5141
  endpoint += `&projectKeyOrId=${encodeURIComponent(args.projectKeyOrId)}`;
@@ -5369,7 +5756,8 @@ if (typeof module !== "undefined") {
5369
5756
  // Policy module exports
5370
5757
  TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
5371
5758
  checkRateLimit, _setClockForTests, _resetBucketsForTests,
5372
- resolveInstanceForTool, _injectDryRunSchema,
5759
+ extractRoutingPrefixes, probeJiraProject, discoverProjectInstance,
5760
+ persistDiscoveredProjects, resolveInstanceForTool, _injectDryRunSchema,
5373
5761
  AUDIT_LOG_PATH,
5374
5762
  };
5375
5763
  }