@thammarongg/jira-mcp 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/README.md +205 -0
- package/dist/client.js +102 -0
- package/dist/config.js +38 -0
- package/dist/index.js +38 -0
- package/dist/install.js +302 -0
- package/dist/tools/boards.js +22 -0
- package/dist/tools/epics.js +61 -0
- package/dist/tools/generic.js +18 -0
- package/dist/tools/issues.js +188 -0
- package/dist/tools/meta.js +13 -0
- package/dist/tools/projects.js +59 -0
- package/dist/tools/sprints.js +106 -0
- package/dist/tools/users.js +36 -0
- package/dist/util.js +24 -0
- package/package.json +51 -0
- package/skill/SKILL.md +102 -0
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# jira-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that exposes the Jira REST API to AI assistants — boards, sprints, epics, issues, JQL search, workflow transitions, comments, worklogs, projects, users, plus a generic passthrough tool for **any** Jira REST endpoint.
|
|
4
|
+
|
|
5
|
+
Works with both **Jira Cloud** (REST API v3) and **Jira Data Center** (REST API v2), auto-detected from the base URL.
|
|
6
|
+
|
|
7
|
+
## Install (one-click)
|
|
8
|
+
|
|
9
|
+
Once published to npm, any MCP client can run it via `npx` — no local build needed:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Claude Code (one-liner)
|
|
13
|
+
claude mcp add jira --env JIRA_BASE_URL=https://your-org.atlassian.net \
|
|
14
|
+
--env JIRA_EMAIL=you@example.com --env JIRA_API_TOKEN=xxx \
|
|
15
|
+
-- npx -y @thammarongg/jira-mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
// Claude Desktop / any MCP client (mcpServers)
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"jira": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@thammarongg/jira-mcp"],
|
|
25
|
+
"env": {
|
|
26
|
+
"JIRA_BASE_URL": "https://your-org.atlassian.net",
|
|
27
|
+
"JIRA_EMAIL": "you@example.com",
|
|
28
|
+
"JIRA_API_TOKEN": "xxx"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
// opencode (opencode.json)
|
|
37
|
+
{
|
|
38
|
+
"mcp": {
|
|
39
|
+
"jira": {
|
|
40
|
+
"type": "local",
|
|
41
|
+
"command": ["npx", "-y", "@thammarongg/jira-mcp"],
|
|
42
|
+
"environment": {
|
|
43
|
+
"JIRA_BASE_URL": "https://your-org.atlassian.net",
|
|
44
|
+
"JIRA_EMAIL": "you@example.com",
|
|
45
|
+
"JIRA_API_TOKEN": "xxx"
|
|
46
|
+
},
|
|
47
|
+
"enabled": true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Publishing
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm login
|
|
57
|
+
npm publish # prepublishOnly runs build + smoke test first
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Local development (no publish)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install
|
|
64
|
+
npm run build
|
|
65
|
+
# run directly: node dist/index.js (or `npm run dev` via tsx)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Configuration (environment variables)
|
|
69
|
+
|
|
70
|
+
| Variable | Required | Description |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `JIRA_BASE_URL` | yes | `https://your-org.atlassian.net` (Cloud) or `https://jira.yourcompany.com` (DC) |
|
|
73
|
+
| `JIRA_EMAIL` | Cloud | Your Atlassian account email |
|
|
74
|
+
| `JIRA_API_TOKEN` | yes | API token (Cloud: id.atlassian.com → Security → API tokens; DC: personal access token) |
|
|
75
|
+
| `JIRA_USERNAME` | DC | Username (alternative to `JIRA_EMAIL`) |
|
|
76
|
+
| `JIRA_PASSWORD` | DC | App password (alternative to `JIRA_API_TOKEN`) |
|
|
77
|
+
| `JIRA_API_VERSION` | no | Force `2` or `3`. Default: auto (Cloud → 3, DC → 2) |
|
|
78
|
+
| `JIRA_TIMEOUT_MS` | no | Request timeout, default `30000` |
|
|
79
|
+
|
|
80
|
+
Quick auth check:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
JIRA_BASE_URL=... JIRA_EMAIL=... JIRA_API_TOKEN=... node dist/index.js
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then call the `get_current_user` tool from your MCP client — it verifies credentials.
|
|
87
|
+
|
|
88
|
+
## Using a local checkout
|
|
89
|
+
|
|
90
|
+
Prefer not to publish? Point the client at the built file instead of `npx` —
|
|
91
|
+
same env vars as above:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude mcp add jira --env JIRA_BASE_URL=https://your-org.atlassian.net \
|
|
95
|
+
--env JIRA_EMAIL=you@example.com --env JIRA_API_TOKEN=xxx \
|
|
96
|
+
-- node /absolute/path/to/jira-mcp/dist/index.js
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
// opencode (opencode.json)
|
|
101
|
+
{
|
|
102
|
+
"mcp": {
|
|
103
|
+
"jira": {
|
|
104
|
+
"type": "local",
|
|
105
|
+
"command": ["node", "/absolute/path/to/jira-mcp/dist/index.js"],
|
|
106
|
+
"environment": {
|
|
107
|
+
"JIRA_BASE_URL": "https://your-org.atlassian.net",
|
|
108
|
+
"JIRA_EMAIL": "you@example.com",
|
|
109
|
+
"JIRA_API_TOKEN": "xxx"
|
|
110
|
+
},
|
|
111
|
+
"enabled": true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Agent skill
|
|
118
|
+
|
|
119
|
+
A ready-made agent skill (setup + workflow guidance for the tools) ships in
|
|
120
|
+
[`skill/SKILL.md`](skill/SKILL.md). Install it by copying to your skills
|
|
121
|
+
directory:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
mkdir -p ~/.claude/skills/jira && cp skill/SKILL.md ~/.claude/skills/jira/
|
|
125
|
+
# or for opencode / shared agents:
|
|
126
|
+
mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tools
|
|
130
|
+
|
|
131
|
+
### Boards & sprints (Agile API)
|
|
132
|
+
|
|
133
|
+
| Tool | Description |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `list_boards` | List boards (filter by type/name/project) |
|
|
136
|
+
| `get_board` | Board details incl. projects |
|
|
137
|
+
| `list_sprints` | Sprints on a board (state: active/closed/future) |
|
|
138
|
+
| `get_sprint` | Sprint details |
|
|
139
|
+
| `create_sprint` | New sprint on a board |
|
|
140
|
+
| `update_sprint` | Rename, reschedule, change goal/state |
|
|
141
|
+
| `close_sprint` | Close a sprint |
|
|
142
|
+
| `get_sprint_issues` | Issues in a sprint |
|
|
143
|
+
| `get_sprint_view` | Full UI-like sprint view (rapid view: board + sprint + issues) |
|
|
144
|
+
| `get_backlog` | Board backlog via JQL (`sprint IS NONE ORDER BY rank`) |
|
|
145
|
+
|
|
146
|
+
### Epics
|
|
147
|
+
|
|
148
|
+
| Tool | Description |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `list_epics` / `get_epic` / `get_epic_issues` | Read epics |
|
|
151
|
+
| `create_epic` | New epic on a board |
|
|
152
|
+
| `move_issue_to_epic` | Add an issue to an epic |
|
|
153
|
+
| `get_epic_meta` | Epic issue-type metadata |
|
|
154
|
+
|
|
155
|
+
### Issues
|
|
156
|
+
|
|
157
|
+
| Tool | Description |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `get_issue` | Issue by key |
|
|
160
|
+
| `create_issue` | Create (supports custom fields) |
|
|
161
|
+
| `update_issue` | Set fields and/or relative `update` ops |
|
|
162
|
+
| `delete_issue` | Delete |
|
|
163
|
+
| `search_issues` | **JQL search** with pagination |
|
|
164
|
+
| `get_issue_create_meta` | Discover projects/types/required fields |
|
|
165
|
+
| `get_issue_transitions` / `transition_issue` | Workflow transitions |
|
|
166
|
+
| `assign_issue` | Assign/unassign |
|
|
167
|
+
| `add_comment` / `list_comments` / `delete_comment` | Comments |
|
|
168
|
+
| `get_issue_worklogs` / `add_worklog` | Time tracking |
|
|
169
|
+
|
|
170
|
+
### Projects, users, meta
|
|
171
|
+
|
|
172
|
+
`list_projects`, `get_project`, `get_project_components`, `create_project_component`, `get_project_issue_types`, `get_project_roles`, `get_project_versions`, `get_current_user`, `find_users`, `get_user`, `get_fields`, `get_issue_types`
|
|
173
|
+
|
|
174
|
+
### Escape hatch
|
|
175
|
+
|
|
176
|
+
| Tool | Description |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| `jira_api` | Raw call to any `/rest/...` endpoint (method, path, query, body) — covers the full Jira REST API |
|
|
179
|
+
|
|
180
|
+
## Typical agent workflow
|
|
181
|
+
|
|
182
|
+
1. `list_boards` → pick a board
|
|
183
|
+
2. `list_sprints` (state: active) → pick a sprint
|
|
184
|
+
3. `get_sprint_issues` or `get_sprint_view` → see the work
|
|
185
|
+
4. `search_issues` with JQL for anything custom
|
|
186
|
+
5. `create_issue` / `transition_issue` / `add_comment` to act
|
|
187
|
+
6. Anything else → `jira_api`
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm run dev # run from source via tsx
|
|
193
|
+
npm run build # compile to dist/
|
|
194
|
+
npm run typecheck # tsc --noEmit
|
|
195
|
+
node scripts/smoke.mjs # stdio handshake + tools/list smoke test
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Notes & limitations
|
|
199
|
+
|
|
200
|
+
- Auth is HTTP Basic (email+token for Cloud, username+token/password for DC) — the standard for Jira REST.
|
|
201
|
+
- Pagination: list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
|
|
202
|
+
- `get_backlog` is implemented via JQL since the Agile API has no direct backlog endpoint.
|
|
203
|
+
- Rapid view IDs are computed as `boardId * 10^13 + sprintId` (Jira's documented convention).
|
|
204
|
+
- Comment bodies use the `body` field on both Cloud (v3) and Data Center (v2).
|
|
205
|
+
- `jira_api` paths must resolve under `/rest/` — paths that would escape it (e.g. via `..` segments) are rejected, and `?`/`#` must be passed via `query`.
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export class JiraApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
body;
|
|
4
|
+
constructor(status, body, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.body = body;
|
|
8
|
+
this.name = "JiraApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class JiraClient {
|
|
12
|
+
cfg;
|
|
13
|
+
constructor(cfg) {
|
|
14
|
+
this.cfg = cfg;
|
|
15
|
+
}
|
|
16
|
+
get apiVersion() {
|
|
17
|
+
return this.cfg.apiVersion;
|
|
18
|
+
}
|
|
19
|
+
get isCloud() {
|
|
20
|
+
return this.cfg.isCloud;
|
|
21
|
+
}
|
|
22
|
+
async request(method, path, opts = {}) {
|
|
23
|
+
const url = new URL(`${this.cfg.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
24
|
+
if (!url.pathname.startsWith("/rest/")) {
|
|
25
|
+
throw new Error(`Jira API path must start with /rest/ (resolved to ${url.pathname})`);
|
|
26
|
+
}
|
|
27
|
+
if (opts.query) {
|
|
28
|
+
for (const [key, value] of Object.entries(opts.query)) {
|
|
29
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
30
|
+
url.searchParams.set(key, String(value));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const headers = {
|
|
35
|
+
Authorization: this.cfg.authHeader,
|
|
36
|
+
Accept: "application/json",
|
|
37
|
+
...opts.headers,
|
|
38
|
+
};
|
|
39
|
+
let bodyInit;
|
|
40
|
+
if (opts.body !== undefined) {
|
|
41
|
+
headers["Content-Type"] = "application/json";
|
|
42
|
+
bodyInit = JSON.stringify(opts.body);
|
|
43
|
+
}
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method,
|
|
46
|
+
headers,
|
|
47
|
+
body: bodyInit,
|
|
48
|
+
signal: AbortSignal.timeout(this.cfg.timeoutMs),
|
|
49
|
+
});
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
let data;
|
|
52
|
+
try {
|
|
53
|
+
data = text ? JSON.parse(text) : null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
data = text;
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new JiraApiError(res.status, data, `Jira API ${method} ${path} failed with HTTP ${res.status}: ${extractErrorMessage(data) ?? res.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
apiGet(path, query) {
|
|
64
|
+
return this.request("GET", `/rest/api/${this.cfg.apiVersion}${path}`, { query });
|
|
65
|
+
}
|
|
66
|
+
apiPost(path, body) {
|
|
67
|
+
return this.request("POST", `/rest/api/${this.cfg.apiVersion}${path}`, { body });
|
|
68
|
+
}
|
|
69
|
+
apiPut(path, body) {
|
|
70
|
+
return this.request("PUT", `/rest/api/${this.cfg.apiVersion}${path}`, { body });
|
|
71
|
+
}
|
|
72
|
+
apiDelete(path) {
|
|
73
|
+
return this.request("DELETE", `/rest/api/${this.cfg.apiVersion}${path}`);
|
|
74
|
+
}
|
|
75
|
+
agileGet(path, query) {
|
|
76
|
+
return this.request("GET", `/rest/agile/1.0${path}`, { query });
|
|
77
|
+
}
|
|
78
|
+
agilePost(path, body) {
|
|
79
|
+
return this.request("POST", `/rest/agile/1.0${path}`, { body });
|
|
80
|
+
}
|
|
81
|
+
agilePut(path, body) {
|
|
82
|
+
return this.request("PUT", `/rest/agile/1.0${path}`, { body });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function extractErrorMessage(body) {
|
|
86
|
+
if (typeof body === "string" && body.trim()) {
|
|
87
|
+
const trimmed = body.trim();
|
|
88
|
+
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}…` : trimmed;
|
|
89
|
+
}
|
|
90
|
+
if (body && typeof body === "object") {
|
|
91
|
+
const obj = body;
|
|
92
|
+
if (Array.isArray(obj.errorMessages) && obj.errorMessages.length > 0) {
|
|
93
|
+
return obj.errorMessages.map(String).join("; ");
|
|
94
|
+
}
|
|
95
|
+
if (typeof obj.message === "string" && obj.message)
|
|
96
|
+
return obj.message;
|
|
97
|
+
if (obj.errors && typeof obj.errors === "object") {
|
|
98
|
+
return Object.values(obj.errors).map(String).join("; ");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function loadConfig(env = process.env) {
|
|
2
|
+
const rawBase = (env.JIRA_BASE_URL ?? "").trim();
|
|
3
|
+
if (!rawBase) {
|
|
4
|
+
throw new Error("JIRA_BASE_URL is required, e.g. https://your-org.atlassian.net (Cloud) or https://jira.yourcompany.com (Data Center)");
|
|
5
|
+
}
|
|
6
|
+
let baseUrl;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(rawBase);
|
|
9
|
+
if (!/^https?:$/.test(url.protocol))
|
|
10
|
+
throw new Error("bad protocol");
|
|
11
|
+
baseUrl = `${url.protocol}//${url.host}`;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`JIRA_BASE_URL is not a valid URL: ${rawBase}`);
|
|
15
|
+
}
|
|
16
|
+
const email = (env.JIRA_EMAIL ?? "").trim();
|
|
17
|
+
const username = (env.JIRA_USERNAME ?? "").trim();
|
|
18
|
+
const token = (env.JIRA_API_TOKEN ?? "").trim();
|
|
19
|
+
const password = (env.JIRA_PASSWORD ?? "").trim();
|
|
20
|
+
let authHeader;
|
|
21
|
+
if (email && token) {
|
|
22
|
+
authHeader = basicAuth(email, token);
|
|
23
|
+
}
|
|
24
|
+
else if (username && (token || password)) {
|
|
25
|
+
authHeader = basicAuth(username, token || password);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
throw new Error("Missing credentials. Set JIRA_EMAIL + JIRA_API_TOKEN (Jira Cloud) or JIRA_USERNAME + JIRA_API_TOKEN/JIRA_PASSWORD (Data Center).");
|
|
29
|
+
}
|
|
30
|
+
const isCloud = /(^|\.)atlassian\.net$/i.test(new URL(baseUrl).hostname);
|
|
31
|
+
const rawVersion = (env.JIRA_API_VERSION ?? "").trim();
|
|
32
|
+
const apiVersion = rawVersion === "2" || rawVersion === "3" ? rawVersion : isCloud ? "3" : "2";
|
|
33
|
+
const timeoutMs = Math.max(1000, Number(env.JIRA_TIMEOUT_MS ?? 30000) || 30000);
|
|
34
|
+
return { baseUrl, apiVersion, authHeader, isCloud, timeoutMs };
|
|
35
|
+
}
|
|
36
|
+
function basicAuth(user, secret) {
|
|
37
|
+
return `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
|
|
38
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { JiraClient } from "./client.js";
|
|
6
|
+
import { registerBoardTools } from "./tools/boards.js";
|
|
7
|
+
import { registerSprintTools } from "./tools/sprints.js";
|
|
8
|
+
import { registerEpicTools } from "./tools/epics.js";
|
|
9
|
+
import { registerIssueTools } from "./tools/issues.js";
|
|
10
|
+
import { registerProjectTools } from "./tools/projects.js";
|
|
11
|
+
import { registerUserTools } from "./tools/users.js";
|
|
12
|
+
import { registerMetaTools } from "./tools/meta.js";
|
|
13
|
+
import { registerGenericTools } from "./tools/generic.js";
|
|
14
|
+
import { runInstaller } from "./install.js";
|
|
15
|
+
async function main() {
|
|
16
|
+
if (process.argv[2] === "install") {
|
|
17
|
+
await runInstaller(process.argv.slice(3));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const client = new JiraClient(config);
|
|
22
|
+
const server = new McpServer({ name: "jira", version: "0.1.0" });
|
|
23
|
+
registerBoardTools(server, client);
|
|
24
|
+
registerSprintTools(server, client);
|
|
25
|
+
registerEpicTools(server, client);
|
|
26
|
+
registerIssueTools(server, client);
|
|
27
|
+
registerProjectTools(server, client);
|
|
28
|
+
registerUserTools(server, client);
|
|
29
|
+
registerMetaTools(server, client);
|
|
30
|
+
registerGenericTools(server, client);
|
|
31
|
+
const transport = new StdioServerTransport();
|
|
32
|
+
await server.connect(transport);
|
|
33
|
+
console.error(`jira-mcp ready: ${config.baseUrl} (API v${config.apiVersion}, ${config.isCloud ? "Cloud" : "Data Center"})`);
|
|
34
|
+
}
|
|
35
|
+
main().catch((err) => {
|
|
36
|
+
console.error(`jira-mcp failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import * as readline from "node:readline";
|
|
5
|
+
const PKG = "@thammarongg/jira-mcp";
|
|
6
|
+
const claudeDesktopPath = platform() === "darwin"
|
|
7
|
+
? join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
8
|
+
: join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
9
|
+
const AGENTS = [
|
|
10
|
+
{ id: "claude-code", label: "Claude Code", path: join(homedir(), ".claude.json"), kind: "mcpServers" },
|
|
11
|
+
{ id: "opencode", label: "OpenCode", path: join(homedir(), ".config", "opencode", "opencode.json"), kind: "opencode" },
|
|
12
|
+
{ id: "codex", label: "Codex", path: join(homedir(), ".codex", "config.toml"), kind: "codex" },
|
|
13
|
+
{ id: "cursor", label: "Cursor", path: join(homedir(), ".cursor", "mcp.json"), kind: "mcpServers" },
|
|
14
|
+
{ id: "claude-desktop", label: "Claude Desktop", path: claudeDesktopPath, kind: "mcpServers" },
|
|
15
|
+
{ id: "gemini-cli", label: "Gemini CLI", path: join(homedir(), ".gemini", "settings.json"), kind: "mcpServers" },
|
|
16
|
+
];
|
|
17
|
+
function parseFlags(argv) {
|
|
18
|
+
const flags = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
const next = () => {
|
|
22
|
+
const v = argv[++i];
|
|
23
|
+
if (v === undefined)
|
|
24
|
+
throw new Error(`Missing value for ${arg}`);
|
|
25
|
+
return v;
|
|
26
|
+
};
|
|
27
|
+
if (arg === "--agents")
|
|
28
|
+
flags.agents = next();
|
|
29
|
+
else if (arg === "--base-url")
|
|
30
|
+
flags.baseUrl = next();
|
|
31
|
+
else if (arg === "--email")
|
|
32
|
+
flags.email = next();
|
|
33
|
+
else if (arg === "--username")
|
|
34
|
+
flags.username = next();
|
|
35
|
+
else if (arg === "--token")
|
|
36
|
+
flags.token = next();
|
|
37
|
+
else if (arg === "--password")
|
|
38
|
+
flags.password = next();
|
|
39
|
+
else if (arg === "--yes")
|
|
40
|
+
flags.yes = true;
|
|
41
|
+
else if (arg === "--help" || arg === "-h") {
|
|
42
|
+
printUsage();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
throw new Error(`Unknown option: ${arg} (see --help)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return flags;
|
|
50
|
+
}
|
|
51
|
+
function printUsage() {
|
|
52
|
+
console.log(`jira-mcp install — configure the jira MCP server in your agent(s)
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
npx -y ${PKG} install
|
|
56
|
+
npx -y ${PKG} install --agents all --base-url https://x.atlassian.net --email you@x.com --token xxx --yes
|
|
57
|
+
|
|
58
|
+
Options:
|
|
59
|
+
--agents <list> Comma-separated agent ids or 'all': ${AGENTS.map((a) => a.id).join(", ")}
|
|
60
|
+
--base-url <url> Jira base URL (required)
|
|
61
|
+
--email <email> Jira Cloud email (with --token)
|
|
62
|
+
--username <user> Jira Data Center username (with --token or --password)
|
|
63
|
+
--token <token> API token / PAT
|
|
64
|
+
--password <pass> Data Center app password (alternative to --token)
|
|
65
|
+
--yes Skip the confirmation prompt
|
|
66
|
+
-h, --help Show this help
|
|
67
|
+
`);
|
|
68
|
+
}
|
|
69
|
+
export async function runInstaller(argv) {
|
|
70
|
+
const flags = parseFlags(argv);
|
|
71
|
+
const selected = flags.agents ? resolveAgents(flags.agents) : await promptSelection();
|
|
72
|
+
const env = flags.baseUrl && (flags.email || flags.username)
|
|
73
|
+
? buildEnvFromFlags(flags)
|
|
74
|
+
: await promptCredentials();
|
|
75
|
+
console.log("\nWill configure the jira MCP server in:");
|
|
76
|
+
for (const agent of selected)
|
|
77
|
+
console.log(` - ${agent.label.padEnd(15)} ${agent.path}`);
|
|
78
|
+
console.log(`Env vars: ${Object.keys(env).join(", ")} (values not shown)`);
|
|
79
|
+
if (!flags.yes) {
|
|
80
|
+
const rl = makeRl();
|
|
81
|
+
const answer = (await ask(rl, "\nProceed? [Y/n] ")).trim().toLowerCase();
|
|
82
|
+
rl.close();
|
|
83
|
+
if (answer !== "" && answer !== "y" && answer !== "yes") {
|
|
84
|
+
console.log("Aborted.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let failed = false;
|
|
89
|
+
for (const agent of selected) {
|
|
90
|
+
try {
|
|
91
|
+
const result = writeAgentConfig(agent, env);
|
|
92
|
+
console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
failed = true;
|
|
96
|
+
console.error(` ERR ${agent.label.padEnd(15)} ${agent.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
console.log("\nDone. Restart each agent to pick up the new MCP server.");
|
|
100
|
+
console.log("Verify by asking your agent to call the get_current_user tool.");
|
|
101
|
+
if (failed)
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
104
|
+
function resolveAgents(spec) {
|
|
105
|
+
const norm = spec.trim().toLowerCase();
|
|
106
|
+
if (norm === "all" || norm === "a")
|
|
107
|
+
return [...AGENTS];
|
|
108
|
+
const ids = norm.split(/[,\s]+/).filter(Boolean);
|
|
109
|
+
const selected = ids.map((id) => {
|
|
110
|
+
const agent = AGENTS.find((a) => a.id === id);
|
|
111
|
+
if (!agent)
|
|
112
|
+
throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a) => a.id).join(", ")} or all`);
|
|
113
|
+
return agent;
|
|
114
|
+
});
|
|
115
|
+
if (selected.length === 0)
|
|
116
|
+
throw new Error("No agents selected");
|
|
117
|
+
return selected;
|
|
118
|
+
}
|
|
119
|
+
function buildEnvFromFlags(flags) {
|
|
120
|
+
const env = { JIRA_BASE_URL: flags.baseUrl };
|
|
121
|
+
if (flags.email) {
|
|
122
|
+
if (!flags.token)
|
|
123
|
+
throw new Error("--email requires --token");
|
|
124
|
+
env.JIRA_EMAIL = flags.email;
|
|
125
|
+
env.JIRA_API_TOKEN = flags.token;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
if (!flags.token && !flags.password)
|
|
129
|
+
throw new Error("--username requires --token or --password");
|
|
130
|
+
env.JIRA_USERNAME = flags.username;
|
|
131
|
+
if (flags.token)
|
|
132
|
+
env.JIRA_API_TOKEN = flags.token;
|
|
133
|
+
if (flags.password)
|
|
134
|
+
env.JIRA_PASSWORD = flags.password;
|
|
135
|
+
}
|
|
136
|
+
return env;
|
|
137
|
+
}
|
|
138
|
+
function makeRl() {
|
|
139
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
140
|
+
}
|
|
141
|
+
function ask(rl, question) {
|
|
142
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
143
|
+
}
|
|
144
|
+
function askPassword(promptText) {
|
|
145
|
+
const stdin = process.stdin;
|
|
146
|
+
if (typeof stdin.setRawMode !== "function") {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
process.stdout.write(`${promptText} (non-interactive: value visible)\n`);
|
|
149
|
+
const rl = makeRl();
|
|
150
|
+
rl.question("", (v) => {
|
|
151
|
+
rl.close();
|
|
152
|
+
resolve(v.trim());
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
let value = "";
|
|
158
|
+
const done = () => {
|
|
159
|
+
stdin.removeListener("data", onData);
|
|
160
|
+
stdin.setRawMode(false);
|
|
161
|
+
process.stdout.write("\n");
|
|
162
|
+
resolve(value);
|
|
163
|
+
};
|
|
164
|
+
const onData = (buf) => {
|
|
165
|
+
for (const ch of buf.toString("utf8")) {
|
|
166
|
+
if (ch === "\r" || ch === "\n") {
|
|
167
|
+
done();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (ch === "\u007f" || ch === "\b")
|
|
171
|
+
value = value.slice(0, -1);
|
|
172
|
+
else if (ch >= " ")
|
|
173
|
+
value += ch;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
process.stdout.write(promptText);
|
|
177
|
+
stdin.setRawMode(true);
|
|
178
|
+
stdin.on("data", onData);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async function promptSelection() {
|
|
182
|
+
console.log(`\njira-mcp installer — select agents to configure:\n`);
|
|
183
|
+
console.log(" a) Select All");
|
|
184
|
+
AGENTS.forEach((agent, i) => {
|
|
185
|
+
console.log(` ${i + 1}) ${agent.label.padEnd(15)} ${agent.path}`);
|
|
186
|
+
});
|
|
187
|
+
const rl = makeRl();
|
|
188
|
+
for (;;) {
|
|
189
|
+
const answer = (await ask(rl, "\nChoice (e.g. 1,3 or a): ")).trim().toLowerCase();
|
|
190
|
+
rl.close();
|
|
191
|
+
try {
|
|
192
|
+
return answer === "a" || answer === "all" ? [...AGENTS] : resolveAgents(answer);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function promptCredentials() {
|
|
200
|
+
const rl = makeRl();
|
|
201
|
+
const baseUrl = (await ask(rl, "Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
|
|
202
|
+
if (!/^https?:\/\/.+/i.test(baseUrl)) {
|
|
203
|
+
rl.close();
|
|
204
|
+
throw new Error("Jira base URL must start with http(s)://");
|
|
205
|
+
}
|
|
206
|
+
const mode = (await ask(rl, "Deployment: [1] Jira Cloud [2] Jira Data Center (default 1): ")).trim();
|
|
207
|
+
const isCloud = mode === "" || mode === "1";
|
|
208
|
+
const user = (await ask(rl, isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
|
|
209
|
+
if (!user) {
|
|
210
|
+
rl.close();
|
|
211
|
+
throw new Error(isCloud ? "Email is required" : "Username is required");
|
|
212
|
+
}
|
|
213
|
+
const secret = await askPassword(isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
|
|
214
|
+
rl.resume();
|
|
215
|
+
rl.close();
|
|
216
|
+
if (!secret)
|
|
217
|
+
throw new Error("API token is required");
|
|
218
|
+
const env = { JIRA_BASE_URL: baseUrl };
|
|
219
|
+
if (isCloud) {
|
|
220
|
+
env.JIRA_EMAIL = user;
|
|
221
|
+
env.JIRA_API_TOKEN = secret;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
env.JIRA_USERNAME = user;
|
|
225
|
+
env.JIRA_API_TOKEN = secret;
|
|
226
|
+
}
|
|
227
|
+
return env;
|
|
228
|
+
}
|
|
229
|
+
function writeAgentConfig(agent, env) {
|
|
230
|
+
if (agent.kind === "codex")
|
|
231
|
+
return upsertCodex(agent.path, env);
|
|
232
|
+
if (agent.kind === "opencode") {
|
|
233
|
+
return mergeJsonFile(agent.path, (obj) => {
|
|
234
|
+
const mcp = (obj.mcp ??= {});
|
|
235
|
+
mcp.jira = {
|
|
236
|
+
type: "local",
|
|
237
|
+
command: ["npx", "-y", PKG],
|
|
238
|
+
environment: env,
|
|
239
|
+
enabled: true,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return mergeJsonFile(agent.path, (obj) => {
|
|
244
|
+
const mcpServers = (obj.mcpServers ??= {});
|
|
245
|
+
mcpServers.jira = { command: "npx", args: ["-y", PKG], env };
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function mergeJsonFile(path, mutate) {
|
|
249
|
+
const existed = existsSync(path);
|
|
250
|
+
let obj = {};
|
|
251
|
+
if (existed) {
|
|
252
|
+
const raw = readFileSync(path, "utf8");
|
|
253
|
+
try {
|
|
254
|
+
const parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
255
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
256
|
+
obj = parsed;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
copyFileSync(path, `${path}.bak`);
|
|
260
|
+
console.log(` ! ${path} was not valid JSON — backed up to ${path}.bak, starting fresh`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
mutate(obj);
|
|
264
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
265
|
+
writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
|
|
266
|
+
return existed ? "updated" : "created";
|
|
267
|
+
}
|
|
268
|
+
function tomlStr(v) {
|
|
269
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
270
|
+
}
|
|
271
|
+
function upsertCodex(path, env) {
|
|
272
|
+
const block = [
|
|
273
|
+
"[mcp_servers.jira]",
|
|
274
|
+
'command = "npx"',
|
|
275
|
+
`args = ["-y", ${tomlStr(PKG)}]`,
|
|
276
|
+
`env = { ${Object.entries(env)
|
|
277
|
+
.map(([k, v]) => `${k} = ${tomlStr(v)}`)
|
|
278
|
+
.join(", ")} }`,
|
|
279
|
+
];
|
|
280
|
+
if (!existsSync(path)) {
|
|
281
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
282
|
+
writeFileSync(path, block.join("\n") + "\n");
|
|
283
|
+
return "created";
|
|
284
|
+
}
|
|
285
|
+
const raw = readFileSync(path, "utf8");
|
|
286
|
+
const lines = raw.split("\n");
|
|
287
|
+
const start = lines.findIndex((l) => /^\s*\[mcp_servers\.jira\]\s*$/.test(l));
|
|
288
|
+
if (start >= 0) {
|
|
289
|
+
let end = lines.length;
|
|
290
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
291
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
292
|
+
end = i;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
lines.splice(start, end - start, ...block);
|
|
297
|
+
writeFileSync(path, lines.join("\n"));
|
|
298
|
+
return "updated";
|
|
299
|
+
}
|
|
300
|
+
writeFileSync(path, raw.replace(/\s*$/, "") + "\n\n" + block.join("\n") + "\n");
|
|
301
|
+
return "updated";
|
|
302
|
+
}
|