mcp-multi-jira 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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2025 iipanda
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # mcp-multi-jira
2
+
3
+ [![CI](https://github.com/iipanda/mcp-multi-jira/actions/workflows/ci.yml/badge.svg)](https://github.com/iipanda/mcp-multi-jira/actions/workflows/ci.yml)
4
+
5
+ Multi-account Jira MCP server. Configure multiple Atlassian Jira accounts and route tool calls by account alias.
6
+
7
+ ## Project goals
8
+
9
+ Most Jira MCP setups are single-account, which gets painful if you regularly switch between:
10
+
11
+ - work + personal Jira
12
+ - multiple clients/tenants
13
+ - separate Jira sites with different permissions
14
+
15
+ This project exists to provide a **single MCP server** that can hold **multiple Jira accounts** and route every tool call via an explicit `account` parameter (so your agent config stays stable while you tell it to use different accounts in prompts).
16
+
17
+ This project has features like:
18
+
19
+ - optional encrypted or OS keychain token storage
20
+ - works with common MCP clients (Cursor, Claude Code, Codex CLI)
21
+ - automatic installation of MCP configs for supported agents
22
+
23
+ ## Quick start
24
+
25
+ First, set up your account(s):
26
+
27
+ ```bash
28
+ npx -y mcp-multi-jira login Work
29
+ npx -y mcp-multi-jira login Personal
30
+ ```
31
+
32
+ Then, install the MCP in your favorite coding agent:
33
+
34
+ ```bash
35
+ npx -y mcp-multi-jira install
36
+ ```
37
+
38
+ That's it! Restart your coding agent / IDE to pick up the new MCP server.
39
+
40
+ ## CLI reference
41
+
42
+ Log in an account:
43
+
44
+ ```bash
45
+ mcp-multi-jira login WorkJira
46
+ ```
47
+
48
+ List accounts:
49
+
50
+ ```bash
51
+ mcp-multi-jira list
52
+ ```
53
+
54
+ Start the server:
55
+ (Note: server will be started automatically by the agents if you use the install command)
56
+
57
+ ```bash
58
+ mcp-multi-jira serve
59
+ ```
60
+
61
+ Install MCP configuration for supported agents:
62
+
63
+ ```bash
64
+ mcp-multi-jira install
65
+ ```
66
+
67
+ ## Advanced usage
68
+
69
+ ### Change token storage
70
+
71
+ By default, tokens are stored in a plaintext file. To use encryption or the OS keychain, set the default backend once with:
72
+
73
+ ```bash
74
+ # Options: plain (default), encrypted, keychain
75
+ mcp-multi-jira token-store encrypted
76
+ ```
77
+
78
+ If you use encrypted storage, make sure your environment has the master password set, otherwise the MCP will fail to start:
79
+
80
+ ```bash
81
+ export MCP_JIRA_TOKEN_PASSWORD="your-master-password"
82
+ ```
83
+
84
+ Plaintext tokens are stored at `~/.mcp-jira/tokens.json` (do not use on shared machines).
85
+
86
+ When switching token stores and accounts already exist, the CLI will prompt to migrate tokens to the new backend.
87
+
88
+ ### Override OAuth client
89
+
90
+ ```bash
91
+ export MCP_JIRA_CLIENT_ID="your-client-id"
92
+ export MCP_JIRA_CLIENT_SECRET="your-client-secret"
93
+ ```
94
+
95
+ ## Manual agent configuration
96
+
97
+ You can auto-install agent configs:
98
+
99
+ ```bash
100
+ mcp-multi-jira install
101
+ ```
102
+
103
+ Below are manual snippets for advanced setups.
104
+
105
+ ### Cursor
106
+
107
+ `~/.cursor/mcp.json` (or per-project `.cursor/mcp.json`):
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "mcp-jira": {
113
+ "command": "npx",
114
+ "args": ["-y", "mcp-multi-jira", "serve"]
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ ### Claude Code
121
+
122
+ `~/.claude/mcp_servers.json`:
123
+
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "mcp-jira": {
128
+ "command": "npx",
129
+ "args": ["-y", "mcp-multi-jira", "serve"]
130
+ }
131
+ }
132
+ }
133
+ ```
134
+
135
+ ### OpenAI Codex CLI
136
+
137
+ `~/.codex/config.toml`:
138
+
139
+ ```toml
140
+ [mcp_servers.mcp_jira]
141
+ command = "npx"
142
+ args = ["-y", "mcp-multi-jira", "serve"]
143
+ ```
@@ -0,0 +1,279 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import toml from "@iarna/toml";
5
+ import { checkbox, confirm, password } from "@inquirer/prompts";
6
+ import { backupFile } from "../utils/fs.js";
7
+ import { info, warn } from "../utils/log.js";
8
+ const MCP_SERVER_NAME = "mcp-jira";
9
+ function isRecord(value) {
10
+ return Boolean(value) && typeof value === "object";
11
+ }
12
+ function ensureRecord(value) {
13
+ return isRecord(value) ? value : {};
14
+ }
15
+ function getOrCreateRecord(container, key) {
16
+ const current = container[key];
17
+ if (isRecord(current)) {
18
+ return current;
19
+ }
20
+ const next = {};
21
+ container[key] = next;
22
+ return next;
23
+ }
24
+ function createMcpServerEntry(env) {
25
+ return {
26
+ command: "npx",
27
+ args: ["-y", "mcp-multi-jira", "serve"],
28
+ ...(Object.keys(env).length > 0 ? { env } : {}),
29
+ };
30
+ }
31
+ function resolveEnvDefaults() {
32
+ return {
33
+ clientId: process.env.MCP_JIRA_CLIENT_ID || process.env.ATLASSIAN_CLIENT_ID || "",
34
+ clientSecret: process.env.MCP_JIRA_CLIENT_SECRET ||
35
+ process.env.ATLASSIAN_CLIENT_SECRET ||
36
+ "",
37
+ tokenPassword: process.env.MCP_JIRA_TOKEN_PASSWORD || "",
38
+ tokenStore: process.env.MCP_JIRA_TOKEN_STORE || "",
39
+ };
40
+ }
41
+ function normalizeTokenStore(value) {
42
+ if (!value) {
43
+ return null;
44
+ }
45
+ const normalized = value.toLowerCase();
46
+ if (normalized === "encrypted" ||
47
+ normalized === "plain" ||
48
+ normalized === "keychain") {
49
+ return normalized;
50
+ }
51
+ return null;
52
+ }
53
+ async function promptEnv(tokenStore) {
54
+ const defaults = resolveEnvDefaults();
55
+ if (tokenStore !== "encrypted") {
56
+ return { tokenPassword: "" };
57
+ }
58
+ if (defaults.tokenPassword) {
59
+ return { tokenPassword: defaults.tokenPassword };
60
+ }
61
+ const tokenPassword = await password({
62
+ message: "Master token password for encrypted storage (leave blank to skip):",
63
+ });
64
+ return { tokenPassword };
65
+ }
66
+ function buildEnv(envInput) {
67
+ const env = {};
68
+ if (envInput.clientId) {
69
+ env.MCP_JIRA_CLIENT_ID = envInput.clientId;
70
+ }
71
+ if (envInput.clientSecret) {
72
+ env.MCP_JIRA_CLIENT_SECRET = envInput.clientSecret;
73
+ }
74
+ if (envInput.tokenPassword) {
75
+ env.MCP_JIRA_TOKEN_PASSWORD = envInput.tokenPassword;
76
+ }
77
+ if (envInput.tokenStore) {
78
+ env.MCP_JIRA_TOKEN_STORE = String(envInput.tokenStore);
79
+ }
80
+ if (envInput.tokenStore === "keychain") {
81
+ env.MCP_JIRA_USE_KEYCHAIN = "true";
82
+ }
83
+ return env;
84
+ }
85
+ function isJiraEntry(name, entry) {
86
+ const lowered = name.toLowerCase();
87
+ if (lowered.includes("jira") || lowered.includes("atlassian")) {
88
+ return true;
89
+ }
90
+ const serialized = JSON.stringify(entry ?? "").toLowerCase();
91
+ return (serialized.includes("mcp.atlassian.com") ||
92
+ serialized.includes("mcp-atlassian") ||
93
+ serialized.includes("jira_url"));
94
+ }
95
+ async function removeJiraEntries(entries, message, configPath) {
96
+ const jiraKeys = Object.keys(entries).filter((key) => isJiraEntry(key, entries[key]));
97
+ if (jiraKeys.length === 0) {
98
+ return true;
99
+ }
100
+ const remove = await confirm({
101
+ message: `${message} (${jiraKeys.join(", ")}). Remove them?`,
102
+ default: false,
103
+ });
104
+ if (!remove) {
105
+ warn(`Skipping config update for ${configPath}.`);
106
+ return false;
107
+ }
108
+ await backupFile(configPath);
109
+ for (const key of jiraKeys) {
110
+ delete entries[key];
111
+ }
112
+ return true;
113
+ }
114
+ async function readJsonConfig(filePath) {
115
+ try {
116
+ const raw = await fs.readFile(filePath, "utf8");
117
+ return { config: ensureRecord(JSON.parse(raw)), exists: true };
118
+ }
119
+ catch (err) {
120
+ if (err.code === "ENOENT") {
121
+ return { config: {}, exists: false };
122
+ }
123
+ throw err;
124
+ }
125
+ }
126
+ async function loadClaudeConfig() {
127
+ const home = os.homedir();
128
+ const mcpServersPath = path.join(home, ".claude", "mcp_servers.json");
129
+ const globalConfigPath = path.join(home, ".claude.json");
130
+ const mcpFile = await readJsonConfig(mcpServersPath);
131
+ if (mcpFile.exists) {
132
+ return { config: mcpFile.config, targetPath: mcpServersPath, mode: "mcp" };
133
+ }
134
+ const globalFile = await readJsonConfig(globalConfigPath);
135
+ return {
136
+ config: globalFile.config,
137
+ targetPath: globalConfigPath,
138
+ mode: "project",
139
+ };
140
+ }
141
+ async function updateCursorConfig(configPath, env) {
142
+ let config = { mcpServers: {} };
143
+ let exists = false;
144
+ try {
145
+ const raw = await fs.readFile(configPath, "utf8");
146
+ config = ensureRecord(JSON.parse(raw));
147
+ exists = true;
148
+ }
149
+ catch (err) {
150
+ if (err.code !== "ENOENT") {
151
+ throw err;
152
+ }
153
+ }
154
+ const mcpServers = getOrCreateRecord(config, "mcpServers");
155
+ if (exists) {
156
+ const shouldContinue = await removeJiraEntries(mcpServers, `Cursor config ${configPath} contains Jira MCP entries`, configPath);
157
+ if (!shouldContinue) {
158
+ return;
159
+ }
160
+ }
161
+ mcpServers[MCP_SERVER_NAME] = createMcpServerEntry(env);
162
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
163
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
164
+ info(`Updated Cursor MCP config: ${configPath}`);
165
+ }
166
+ async function updateCodexConfig(configPath, env) {
167
+ let config = {};
168
+ let exists = false;
169
+ try {
170
+ const raw = await fs.readFile(configPath, "utf8");
171
+ config = ensureRecord(toml.parse(raw));
172
+ exists = true;
173
+ }
174
+ catch (err) {
175
+ if (err.code !== "ENOENT") {
176
+ throw err;
177
+ }
178
+ }
179
+ const servers = getOrCreateRecord(config, "mcp_servers");
180
+ if (exists) {
181
+ const shouldContinue = await removeJiraEntries(servers, "Codex config has Jira MCP entries", configPath);
182
+ if (!shouldContinue) {
183
+ return;
184
+ }
185
+ }
186
+ servers[MCP_SERVER_NAME.replace(/-/g, "_")] = createMcpServerEntry(env);
187
+ config.mcp_servers = servers;
188
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
189
+ await fs.writeFile(configPath, toml.stringify(config), "utf8");
190
+ info(`Updated Codex MCP config: ${configPath}`);
191
+ }
192
+ async function updateClaudeMcpServers(config, targetPath, env) {
193
+ const mcpServers = getOrCreateRecord(config, "mcpServers");
194
+ const shouldContinue = await removeJiraEntries(mcpServers, "Claude MCP config has Jira entries", targetPath);
195
+ if (!shouldContinue) {
196
+ return false;
197
+ }
198
+ mcpServers[MCP_SERVER_NAME] = createMcpServerEntry(env);
199
+ return true;
200
+ }
201
+ async function updateClaudeProjectConfig(config, targetPath, env) {
202
+ const projectPath = process.cwd();
203
+ const projects = getOrCreateRecord(config, "projects");
204
+ const project = getOrCreateRecord(projects, projectPath);
205
+ const mcpServers = getOrCreateRecord(project, "mcpServers");
206
+ const shouldContinue = await removeJiraEntries(mcpServers, `Claude config for ${projectPath} has Jira entries`, targetPath);
207
+ if (!shouldContinue) {
208
+ return false;
209
+ }
210
+ mcpServers[MCP_SERVER_NAME] = createMcpServerEntry(env);
211
+ return true;
212
+ }
213
+ async function updateClaudeConfig(env) {
214
+ const { config, targetPath, mode } = await loadClaudeConfig();
215
+ const updated = mode === "mcp"
216
+ ? await updateClaudeMcpServers(config, targetPath, env)
217
+ : await updateClaudeProjectConfig(config, targetPath, env);
218
+ if (!updated) {
219
+ return;
220
+ }
221
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
222
+ await fs.writeFile(targetPath, JSON.stringify(config, null, 2), "utf8");
223
+ info(`Updated Claude MCP config: ${targetPath}`);
224
+ }
225
+ export async function runInstaller(options) {
226
+ const targets = (await checkbox({
227
+ message: "Select which agents to configure:",
228
+ choices: [
229
+ { name: "Cursor", value: "cursor" },
230
+ { name: "OpenAI Codex CLI", value: "codex" },
231
+ { name: "Claude Code CLI", value: "claude" },
232
+ ],
233
+ required: true,
234
+ }));
235
+ const defaults = resolveEnvDefaults();
236
+ const envStore = normalizeTokenStore(options?.tokenStore) ??
237
+ normalizeTokenStore(process.env.MCP_JIRA_TOKEN_STORE) ??
238
+ (process.env.MCP_JIRA_USE_KEYCHAIN ? "keychain" : null) ??
239
+ normalizeTokenStore(defaults.tokenStore) ??
240
+ "plain";
241
+ const envInput = await promptEnv(envStore);
242
+ const env = buildEnv({
243
+ ...envInput,
244
+ tokenStore: envStore,
245
+ clientId: defaults.clientId,
246
+ clientSecret: defaults.clientSecret,
247
+ });
248
+ for (const target of targets) {
249
+ if (target === "cursor") {
250
+ const home = os.homedir();
251
+ const globalPath = path.join(home, ".cursor", "mcp.json");
252
+ const localPath = path.join(process.cwd(), ".cursor", "mcp.json");
253
+ await updateCursorConfig(globalPath, env);
254
+ try {
255
+ await fs.access(localPath);
256
+ const updateLocal = await confirm({
257
+ message: `Also update local Cursor config at ${localPath}?`,
258
+ default: false,
259
+ });
260
+ if (updateLocal) {
261
+ await updateCursorConfig(localPath, env);
262
+ }
263
+ }
264
+ catch {
265
+ // ignore missing local config
266
+ }
267
+ continue;
268
+ }
269
+ if (target === "codex") {
270
+ const configPath = path.join(os.homedir(), ".codex", "config.toml");
271
+ await updateCodexConfig(configPath, env);
272
+ continue;
273
+ }
274
+ if (target === "claude") {
275
+ await updateClaudeConfig(env);
276
+ }
277
+ }
278
+ info("Installation complete. Restart each agent to load the new MCP configuration.");
279
+ }