@rui.branco/jira-mcp 1.7.5 → 1.7.6
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 +115 -10
- package/index.js +505 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Jira MCP Server
|
|
2
2
|
|
|
3
|
-
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that brings Jira ticket context directly into Claude Code. Fetch complete ticket information including descriptions, comments, attachments, and linked Figma designs without leaving your development environment.
|
|
3
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that brings Jira ticket context directly into any MCP-compatible AI client — Claude Code, Codex CLI, Google's Antigravity (Gemini), Cursor, Windsurf, Zed, and others. Fetch complete ticket information including descriptions, comments, attachments, and linked Figma designs without leaving your development environment.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ When working on development tasks, context switching between Jira and your code
|
|
|
9
9
|
- **Fetching complete ticket context** - Get descriptions, comments, status, and metadata instantly
|
|
10
10
|
- **Downloading attachments** - Image attachments are downloaded and displayed inline
|
|
11
11
|
- **Auto-fetching Figma designs** - Linked Figma URLs are automatically detected and exported as images
|
|
12
|
-
- **Enabling natural queries** - Search tickets with JQL directly from
|
|
12
|
+
- **Enabling natural queries** - Search tickets with JQL directly from your AI client
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
@@ -27,20 +27,44 @@ When working on development tasks, context switching between Jira and your code
|
|
|
27
27
|
### Prerequisites
|
|
28
28
|
|
|
29
29
|
- Node.js 18+
|
|
30
|
-
- [
|
|
30
|
+
- An MCP-compatible AI client (see [Step 1](#step-1-register-with-your-mcp-client) below)
|
|
31
31
|
- Jira Cloud account with API access
|
|
32
32
|
|
|
33
|
-
### Step 1:
|
|
33
|
+
### Step 1: Register with your MCP client
|
|
34
|
+
|
|
35
|
+
Pick the snippet for your client. All of them launch the server over stdio.
|
|
36
|
+
|
|
37
|
+
**Claude Code:**
|
|
34
38
|
|
|
35
39
|
```bash
|
|
36
40
|
claude mcp add --transport stdio jira -- npx -y @rui.branco/jira-mcp
|
|
37
41
|
```
|
|
38
42
|
|
|
43
|
+
**Codex CLI:** add to `~/.codex/config.toml`:
|
|
44
|
+
|
|
45
|
+
```toml
|
|
46
|
+
[mcp_servers.jira]
|
|
47
|
+
command = "npx"
|
|
48
|
+
args = ["-y", "@rui.branco/jira-mcp"]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Google Antigravity / Gemini CLI:** add to `~/.gemini/settings.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"jira": { "command": "npx", "args": ["-y", "@rui.branco/jira-mcp"] }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Cursor, Windsurf, Zed, Cline, Continue, etc.:** add the same `command`/`args` pair to whatever JSON config that client uses for MCP servers (the exact path varies, but the shape is standard across clients).
|
|
62
|
+
|
|
39
63
|
### Step 2: Get Your Jira API Token
|
|
40
64
|
|
|
41
65
|
1. Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
|
42
66
|
2. Click **"Create API token"**
|
|
43
|
-
3. Enter a label (e.g., "
|
|
67
|
+
3. Enter a label (e.g., "jira-mcp")
|
|
44
68
|
4. Click **"Create"**
|
|
45
69
|
5. Copy the token (you won't be able to see it again)
|
|
46
70
|
|
|
@@ -66,7 +90,7 @@ npx @rui.branco/jira-mcp setup
|
|
|
66
90
|
|
|
67
91
|
### Step 4: Verify
|
|
68
92
|
|
|
69
|
-
Restart
|
|
93
|
+
Restart your AI client and check its MCP status (e.g. `/mcp` in Claude Code, `mcp` in Codex CLI, or the equivalent panel in your client) to verify the server is connected.
|
|
70
94
|
|
|
71
95
|
### Alternative: Manual Installation
|
|
72
96
|
|
|
@@ -78,7 +102,7 @@ cd ~/.config/jira-mcp && npm install
|
|
|
78
102
|
node setup.js
|
|
79
103
|
```
|
|
80
104
|
|
|
81
|
-
Then
|
|
105
|
+
Then register the local entry point with your MCP client (replace the `npx -y @rui.branco/jira-mcp` command from Step 1 with `node $HOME/.config/jira-mcp/index.js`). Example for Claude Code:
|
|
82
106
|
|
|
83
107
|
```bash
|
|
84
108
|
claude mcp add --transport stdio jira -- node $HOME/.config/jira-mcp/index.js
|
|
@@ -146,11 +170,11 @@ This MCP automatically detects Figma URLs in ticket descriptions and comments. W
|
|
|
146
170
|
- Figma links are automatically fetched
|
|
147
171
|
- Large frames are split into sections for better readability
|
|
148
172
|
- Images are exported at 2x scale for clarity
|
|
149
|
-
- All images are displayed inline in Claude Code
|
|
173
|
+
- All images are displayed inline in clients that support image content blocks (Claude Code, Codex, Antigravity, etc.)
|
|
150
174
|
|
|
151
175
|
To enable Figma integration:
|
|
152
176
|
1. Install and configure [figma-mcp](https://github.com/rui-branco/figma-mcp)
|
|
153
|
-
2. Restart
|
|
177
|
+
2. Restart your AI client
|
|
154
178
|
3. Figma links will be auto-fetched when you get a ticket
|
|
155
179
|
|
|
156
180
|
## API Reference
|
|
@@ -230,11 +254,92 @@ The server provides clear error messages:
|
|
|
230
254
|
- Tokens are never logged or transmitted except to Jira/Figma APIs
|
|
231
255
|
- Attachments are downloaded to `~/.config/jira-mcp/attachments/`
|
|
232
256
|
|
|
257
|
+
## Safety & Observability
|
|
258
|
+
|
|
259
|
+
Four opt-in controls limit what an agent can do without your approval and give you a trail when something goes wrong. All four are off by default for existing installs — drop the relevant config block into `~/.config/jira-mcp/config.json` to turn them on.
|
|
260
|
+
|
|
261
|
+
### Per-tool scopes
|
|
262
|
+
|
|
263
|
+
Restrict which tools each instance can call. Useful for "this Atlassian instance is read-only" or "this one only allows comments".
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"instances": [
|
|
268
|
+
{
|
|
269
|
+
"name": "prod",
|
|
270
|
+
"email": "you@company.com",
|
|
271
|
+
"token": "...",
|
|
272
|
+
"baseUrl": "https://company.atlassian.net",
|
|
273
|
+
"scopes": { "preset": "read-only" }
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"name": "sandbox",
|
|
277
|
+
"email": "you@company.com",
|
|
278
|
+
"token": "...",
|
|
279
|
+
"baseUrl": "https://sandbox.atlassian.net",
|
|
280
|
+
"scopes": { "preset": "no-destructive", "deny": ["jira_remove_attachment"] }
|
|
281
|
+
}
|
|
282
|
+
]
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Presets: `read-only`, `comments-only`, `no-destructive`, `unrestricted`. You can combine a preset with `allow: [...]` and `deny: [...]` — deny wins. If `scopes` is present, an unknown tool name fails closed. If `scopes` is omitted, the instance behaves as before (no restrictions).
|
|
287
|
+
|
|
288
|
+
### Dry-run mode
|
|
289
|
+
|
|
290
|
+
For any mutating tool call, return the HTTP request that *would* be sent without actually calling Atlassian. Useful for previewing what an agent is about to do.
|
|
291
|
+
|
|
292
|
+
Three layers of precedence (most-specific wins):
|
|
293
|
+
|
|
294
|
+
1. **Per call:** pass `dryRun: true` to any mutating tool.
|
|
295
|
+
2. **Per instance:** add `"dryRun": true` to an instance in `config.json`.
|
|
296
|
+
3. **Environment:** export `JIRA_MCP_DRY_RUN=1` before launching your AI client.
|
|
297
|
+
|
|
298
|
+
Read tools ignore the flag. The response looks like:
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
{
|
|
302
|
+
"dryRun": true,
|
|
303
|
+
"plan": [{ "api": "jira", "method": "POST", "endpoint": "/issue/PROJ-1/comment", "body": { ... } }]
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
For multi-step tools (e.g. `jira_transition` with intermediate statuses) only the first write is captured.
|
|
308
|
+
|
|
309
|
+
### Audit log
|
|
310
|
+
|
|
311
|
+
Every write attempt — successful, failed, denied by scope, denied by rate limit, or dry-run — is appended as a JSON line to `~/.config/jira-mcp/audit.log`. Token-like fields (`token`, `authorization`, `password`, `apiKey`, `fileContent`, etc.) are redacted before they hit disk. Rotates at 10 MB, keeps 5 historical files.
|
|
312
|
+
|
|
313
|
+
Disable with `"audit": { "enabled": false }` at the top level of `config.json`.
|
|
314
|
+
|
|
315
|
+
### Rate limiting
|
|
316
|
+
|
|
317
|
+
Layered in-memory token buckets prevent runaway loops. Defaults:
|
|
318
|
+
|
|
319
|
+
- **global:** 60 writes/minute
|
|
320
|
+
- **per-instance:** 30 writes/minute
|
|
321
|
+
- **destructive tools** (`*_delete_*`, `jira_remove_*`, `confluence_delete_*`): 5/minute
|
|
322
|
+
|
|
323
|
+
On limit hit the tool call fails immediately with a `Rate limit exceeded` error — no sleeping, no queueing. Reads bypass the limiter entirely. Dry-runs don't consume tokens.
|
|
324
|
+
|
|
325
|
+
Override or disable in `config.json`:
|
|
326
|
+
|
|
327
|
+
```json
|
|
328
|
+
{
|
|
329
|
+
"rateLimit": {
|
|
330
|
+
"enabled": true,
|
|
331
|
+
"global": 120,
|
|
332
|
+
"perInstance": 60,
|
|
333
|
+
"destructive": 10
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
233
338
|
## License
|
|
234
339
|
|
|
235
340
|
MIT
|
|
236
341
|
|
|
237
342
|
## Related
|
|
238
343
|
|
|
239
|
-
- [figma-mcp](https://github.com/rui-branco/figma-mcp) - Figma MCP server
|
|
344
|
+
- [figma-mcp](https://github.com/rui-branco/figma-mcp) - Figma MCP server (works with any MCP-compatible AI client)
|
|
240
345
|
- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
|
package/index.js
CHANGED
|
@@ -134,10 +134,321 @@ if (!fs.existsSync(attachmentDir)) {
|
|
|
134
134
|
fs.mkdirSync(attachmentDir, { recursive: true });
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// ============ TOOL METADATA + POLICY ============
|
|
138
|
+
//
|
|
139
|
+
// TOOL_METADATA is the source of truth for op classification — NOT the HTTP
|
|
140
|
+
// method. The new Jira /search/jql endpoint is POST but semantically a read,
|
|
141
|
+
// so deciding "is this a write?" from the request method would wrongly trip
|
|
142
|
+
// audit + rate-limit on every search.
|
|
143
|
+
|
|
144
|
+
const TOOL_METADATA = {
|
|
145
|
+
// ---- Jira reads ----
|
|
146
|
+
jira_get_myself: { product: "jira", op: "read" },
|
|
147
|
+
jira_get_ticket: { product: "jira", op: "read" },
|
|
148
|
+
jira_search: { product: "jira", op: "read" },
|
|
149
|
+
jira_search_users: { product: "jira", op: "read" },
|
|
150
|
+
jira_get_changelog: { product: "jira", op: "read" },
|
|
151
|
+
jira_list_instances: { product: "jira", op: "read" },
|
|
152
|
+
jira_get_teams: { product: "jira", op: "read" },
|
|
153
|
+
jira_get_link_types: { product: "jira", op: "read" },
|
|
154
|
+
jira_get_transitions: { product: "jira", op: "read" },
|
|
155
|
+
jira_get_worklogs: { product: "jira", op: "read" },
|
|
156
|
+
jira_get_sprints: { product: "jira", op: "read" },
|
|
157
|
+
jira_get_boards: { product: "jira", op: "read" },
|
|
158
|
+
jira_get_issue_types: { product: "jira", op: "read" },
|
|
159
|
+
jira_get_priorities: { product: "jira", op: "read" },
|
|
160
|
+
jira_get_components: { product: "jira", op: "read" },
|
|
161
|
+
jira_get_versions: { product: "jira", op: "read" },
|
|
162
|
+
// ---- Jira writes (reversible) ----
|
|
163
|
+
jira_add_comment: { product: "jira", op: "write", category: "comment" },
|
|
164
|
+
jira_reply_comment: { product: "jira", op: "write", category: "comment" },
|
|
165
|
+
jira_edit_comment: { product: "jira", op: "write", category: "comment" },
|
|
166
|
+
jira_transition: { product: "jira", op: "write", category: "transition" },
|
|
167
|
+
jira_update_ticket: { product: "jira", op: "write", category: "ticket" },
|
|
168
|
+
jira_create_ticket: { product: "jira", op: "write", category: "ticket" },
|
|
169
|
+
jira_create_subtask: { product: "jira", op: "write", category: "ticket" },
|
|
170
|
+
jira_clone_ticket: { product: "jira", op: "write", category: "ticket" },
|
|
171
|
+
jira_link_tickets: { product: "jira", op: "write", category: "link" },
|
|
172
|
+
jira_unlink_tickets: { product: "jira", op: "write", category: "link" },
|
|
173
|
+
jira_add_attachment: { product: "jira", op: "write", category: "attachment" },
|
|
174
|
+
jira_add_watcher: { product: "jira", op: "write", category: "watcher" },
|
|
175
|
+
jira_remove_watcher: { product: "jira", op: "write", category: "watcher" },
|
|
176
|
+
jira_add_worklog: { product: "jira", op: "write", category: "worklog" },
|
|
177
|
+
jira_move_to_sprint: { product: "jira", op: "write", category: "sprint" },
|
|
178
|
+
jira_add_instance: { product: "jira", op: "write", category: "config" },
|
|
179
|
+
// ---- Jira destructive ----
|
|
180
|
+
jira_delete_comment: { product: "jira", op: "destructive", category: "comment" },
|
|
181
|
+
jira_delete_ticket: { product: "jira", op: "destructive", category: "ticket" },
|
|
182
|
+
jira_remove_attachment: { product: "jira", op: "destructive", category: "attachment" },
|
|
183
|
+
jira_remove_instance: { product: "jira", op: "destructive", category: "config" },
|
|
184
|
+
// ---- Confluence reads ----
|
|
185
|
+
confluence_get_spaces: { product: "confluence", op: "read" },
|
|
186
|
+
confluence_get_space: { product: "confluence", op: "read" },
|
|
187
|
+
confluence_search: { product: "confluence", op: "read" },
|
|
188
|
+
confluence_get_recent_pages: { product: "confluence", op: "read" },
|
|
189
|
+
confluence_get_space_root_pages: { product: "confluence", op: "read" },
|
|
190
|
+
confluence_get_page_children: { product: "confluence", op: "read" },
|
|
191
|
+
confluence_get_page: { product: "confluence", op: "read" },
|
|
192
|
+
confluence_get_comments: { product: "confluence", op: "read" },
|
|
193
|
+
confluence_get_labels: { product: "confluence", op: "read" },
|
|
194
|
+
confluence_list_attachments: { product: "confluence", op: "read" },
|
|
195
|
+
confluence_download_attachment: { product: "confluence", op: "read" },
|
|
196
|
+
// ---- Confluence writes ----
|
|
197
|
+
confluence_create_space: { product: "confluence", op: "write", category: "space" },
|
|
198
|
+
confluence_update_space: { product: "confluence", op: "write", category: "space" },
|
|
199
|
+
confluence_add_comment: { product: "confluence", op: "write", category: "comment" },
|
|
200
|
+
confluence_update_page: { product: "confluence", op: "write", category: "page" },
|
|
201
|
+
confluence_create_page: { product: "confluence", op: "write", category: "page" },
|
|
202
|
+
confluence_add_label: { product: "confluence", op: "write", category: "label" },
|
|
203
|
+
confluence_remove_label: { product: "confluence", op: "write", category: "label" },
|
|
204
|
+
confluence_upload_attachment: { product: "confluence", op: "write", category: "attachment" },
|
|
205
|
+
// ---- Confluence destructive ----
|
|
206
|
+
confluence_delete_space: { product: "confluence", op: "destructive", category: "space" },
|
|
207
|
+
confluence_delete_page: { product: "confluence", op: "destructive", category: "page" },
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const COMMENT_TOOLS = new Set([
|
|
211
|
+
"jira_add_comment",
|
|
212
|
+
"jira_reply_comment",
|
|
213
|
+
"jira_edit_comment",
|
|
214
|
+
"confluence_add_comment",
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const SCOPE_PRESETS = {
|
|
218
|
+
"read-only": (m) => m.op === "read",
|
|
219
|
+
"comments-only": (m, name) => m.op === "read" || COMMENT_TOOLS.has(name),
|
|
220
|
+
"no-destructive": (m) => m.op !== "destructive",
|
|
221
|
+
unrestricted: () => true,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function checkScope(toolName, instance) {
|
|
225
|
+
const scopes = instance && instance.scopes;
|
|
226
|
+
// Fail-open when instance has no scopes block — preserves existing installs.
|
|
227
|
+
if (!scopes) return { allowed: true };
|
|
228
|
+
|
|
229
|
+
const meta = TOOL_METADATA[toolName];
|
|
230
|
+
// Unknown tool with scopes configured → fail closed.
|
|
231
|
+
if (!meta) {
|
|
232
|
+
return { allowed: false, reason: `Tool '${toolName}' has no metadata; denied under scoped instance '${instance.name}'.` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Explicit deny wins.
|
|
236
|
+
if (Array.isArray(scopes.deny) && scopes.deny.includes(toolName)) {
|
|
237
|
+
return { allowed: false, reason: `Tool '${toolName}' is in instance '${instance.name}' deny list.` };
|
|
238
|
+
}
|
|
239
|
+
// Explicit allow wins after deny.
|
|
240
|
+
if (Array.isArray(scopes.allow) && scopes.allow.includes(toolName)) {
|
|
241
|
+
return { allowed: true };
|
|
242
|
+
}
|
|
243
|
+
// Preset.
|
|
244
|
+
if (scopes.preset) {
|
|
245
|
+
const presetFn = SCOPE_PRESETS[scopes.preset];
|
|
246
|
+
if (!presetFn) {
|
|
247
|
+
return { allowed: false, reason: `Unknown scope preset '${scopes.preset}' on instance '${instance.name}'.` };
|
|
248
|
+
}
|
|
249
|
+
if (presetFn(meta, toolName)) return { allowed: true };
|
|
250
|
+
return { allowed: false, reason: `Tool '${toolName}' (${meta.op}) not permitted by preset '${scopes.preset}' on '${instance.name}'.` };
|
|
251
|
+
}
|
|
252
|
+
// scopes block present but neither preset nor allow listed → deny.
|
|
253
|
+
return { allowed: false, reason: `Tool '${toolName}' not in instance '${instance.name}' allow list.` };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- Dry-run resolution ----
|
|
257
|
+
const DRY_RUN_ENV =
|
|
258
|
+
process.env.JIRA_MCP_DRY_RUN === "1" ||
|
|
259
|
+
process.env.JIRA_MCP_DRY_RUN === "true";
|
|
260
|
+
|
|
261
|
+
function resolveDryRun(toolName, args, instance) {
|
|
262
|
+
const meta = TOOL_METADATA[toolName];
|
|
263
|
+
if (!meta || meta.op === "read") return false;
|
|
264
|
+
if (args && args.dryRun === true) return true;
|
|
265
|
+
if (args && args.dryRun === false) return false;
|
|
266
|
+
if (instance && instance.dryRun === true) return true;
|
|
267
|
+
return DRY_RUN_ENV;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Dry-run execution context. fetchJira/fetchConfluence check this and short-
|
|
271
|
+
// circuit. Only the FIRST write in a chain is captured — multi-write tools
|
|
272
|
+
// (e.g. transition with intermediate steps) report just step 1 with a note.
|
|
273
|
+
const DRY_RUN_ABORT = Symbol("jira-mcp.dry-run-abort");
|
|
274
|
+
const _policyCtx = { dryRun: false, plan: null };
|
|
275
|
+
|
|
276
|
+
function recordDryRunCall(call) {
|
|
277
|
+
if (_policyCtx.plan) _policyCtx.plan.push(call);
|
|
278
|
+
throw DRY_RUN_ABORT;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- Audit log ----
|
|
282
|
+
const AUDIT_LOG_PATH = path.join(
|
|
283
|
+
process.env.HOME || "/tmp",
|
|
284
|
+
".config/jira-mcp/audit.log",
|
|
285
|
+
);
|
|
286
|
+
const AUDIT_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
287
|
+
const AUDIT_LOG_KEEP = 5;
|
|
288
|
+
const SECRET_KEY_PATTERN =
|
|
289
|
+
/^(token|auth|authorization|password|secret|api[_-]?key|file[_-]?content)$/i;
|
|
290
|
+
|
|
291
|
+
function scrubArgs(value) {
|
|
292
|
+
if (value == null) return value;
|
|
293
|
+
if (Array.isArray(value)) return value.map(scrubArgs);
|
|
294
|
+
if (typeof value === "object") {
|
|
295
|
+
const out = {};
|
|
296
|
+
for (const [k, v] of Object.entries(value)) {
|
|
297
|
+
if (SECRET_KEY_PATTERN.test(k)) {
|
|
298
|
+
out[k] = "[REDACTED]";
|
|
299
|
+
} else if (typeof v === "string" && v.length > 4000) {
|
|
300
|
+
out[k] = v.slice(0, 4000) + `…[truncated ${v.length - 4000} chars]`;
|
|
301
|
+
} else {
|
|
302
|
+
out[k] = scrubArgs(v);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function rotateAuditIfNeeded() {
|
|
311
|
+
try {
|
|
312
|
+
const stat = fs.statSync(AUDIT_LOG_PATH);
|
|
313
|
+
if (stat.size < AUDIT_LOG_MAX_BYTES) return;
|
|
314
|
+
} catch {
|
|
315
|
+
return; // file does not exist yet
|
|
316
|
+
}
|
|
317
|
+
// Shift audit.log.N → audit.log.N+1
|
|
318
|
+
for (let i = AUDIT_LOG_KEEP - 1; i >= 1; i--) {
|
|
319
|
+
const src = `${AUDIT_LOG_PATH}.${i}`;
|
|
320
|
+
const dst = `${AUDIT_LOG_PATH}.${i + 1}`;
|
|
321
|
+
try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch {}
|
|
322
|
+
}
|
|
323
|
+
try { fs.renameSync(AUDIT_LOG_PATH, `${AUDIT_LOG_PATH}.1`); } catch {}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function audit(entry) {
|
|
327
|
+
try {
|
|
328
|
+
if (auditConfig.enabled === false) return;
|
|
329
|
+
const auditDir = path.dirname(AUDIT_LOG_PATH);
|
|
330
|
+
if (!fs.existsSync(auditDir)) fs.mkdirSync(auditDir, { recursive: true });
|
|
331
|
+
rotateAuditIfNeeded();
|
|
332
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
|
|
333
|
+
fs.appendFileSync(AUDIT_LOG_PATH, line);
|
|
334
|
+
} catch {
|
|
335
|
+
// Audit failures must not block real writes.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- Rate limiter (token buckets) ----
|
|
340
|
+
let _clock = () => Date.now();
|
|
341
|
+
function _setClockForTests(fn) { _clock = fn; }
|
|
342
|
+
|
|
343
|
+
// Buckets are populated lazily. Defaults are conservative-but-not-annoying.
|
|
344
|
+
// User can override via config.rateLimit = { enabled: bool, global: N, perInstance: N, destructive: N }.
|
|
345
|
+
const DEFAULT_RL = { enabled: true, global: 60, perInstance: 30, destructive: 5 };
|
|
346
|
+
let _rlConfig = { ...DEFAULT_RL };
|
|
347
|
+
const _buckets = new Map();
|
|
348
|
+
|
|
349
|
+
function _bucket(key, capacity) {
|
|
350
|
+
let b = _buckets.get(key);
|
|
351
|
+
if (!b) {
|
|
352
|
+
b = { tokens: capacity, lastRefill: _clock(), capacity };
|
|
353
|
+
_buckets.set(key, b);
|
|
354
|
+
}
|
|
355
|
+
return b;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function _refill(b, refillPerMin) {
|
|
359
|
+
const now = _clock();
|
|
360
|
+
const elapsed = now - b.lastRefill;
|
|
361
|
+
if (elapsed <= 0) return;
|
|
362
|
+
const refill = (elapsed / 60000) * refillPerMin;
|
|
363
|
+
b.tokens = Math.min(b.capacity, b.tokens + refill);
|
|
364
|
+
b.lastRefill = now;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function checkRateLimit(toolName, instance) {
|
|
368
|
+
if (_rlConfig.enabled === false) return { allowed: true };
|
|
369
|
+
const meta = TOOL_METADATA[toolName];
|
|
370
|
+
if (!meta || meta.op === "read") return { allowed: true };
|
|
371
|
+
|
|
372
|
+
const checks = [
|
|
373
|
+
{ key: "global", capacity: _rlConfig.global },
|
|
374
|
+
{ key: `instance:${instance.name}`, capacity: _rlConfig.perInstance },
|
|
375
|
+
];
|
|
376
|
+
if (meta.op === "destructive") {
|
|
377
|
+
checks.push({ key: "destructive", capacity: _rlConfig.destructive });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const c of checks) {
|
|
381
|
+
const b = _bucket(c.key, c.capacity);
|
|
382
|
+
_refill(b, c.capacity);
|
|
383
|
+
if (b.tokens < 1) {
|
|
384
|
+
const retryAfter = Math.ceil(((1 - b.tokens) / c.capacity) * 60);
|
|
385
|
+
return { allowed: false, bucket: c.key, retryAfter };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Consume from all relevant buckets atomically.
|
|
389
|
+
for (const c of checks) {
|
|
390
|
+
const b = _bucket(c.key, c.capacity);
|
|
391
|
+
b.tokens -= 1;
|
|
392
|
+
}
|
|
393
|
+
return { allowed: true };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function _resetBucketsForTests() {
|
|
397
|
+
_buckets.clear();
|
|
398
|
+
_rlConfig = { ...DEFAULT_RL };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Pick up user overrides from config.
|
|
402
|
+
let auditConfig = { enabled: true };
|
|
403
|
+
if (rawConfig.audit && typeof rawConfig.audit === "object") {
|
|
404
|
+
auditConfig = { ...auditConfig, ...rawConfig.audit };
|
|
405
|
+
}
|
|
406
|
+
if (rawConfig.rateLimit && typeof rawConfig.rateLimit === "object") {
|
|
407
|
+
_rlConfig = { ..._rlConfig, ...rawConfig.rateLimit };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Target extractor for audit log.
|
|
411
|
+
function extractTarget(args) {
|
|
412
|
+
if (!args || typeof args !== "object") return undefined;
|
|
413
|
+
return (
|
|
414
|
+
args.issueKey ||
|
|
415
|
+
args.pageId ||
|
|
416
|
+
args.spaceKey ||
|
|
417
|
+
args.commentId ||
|
|
418
|
+
args.boardId ||
|
|
419
|
+
args.attachmentId ||
|
|
420
|
+
args.accountId ||
|
|
421
|
+
undefined
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Resolve instance for policy purposes (must run BEFORE the dispatch handler).
|
|
426
|
+
// Routing keys vary by tool — issueKey is most common but subtask uses
|
|
427
|
+
// parentKey, link uses inwardIssueKey, create_ticket uses projectKey, and
|
|
428
|
+
// Confluence tools route by spaceKey. If a tool genuinely targets a specific
|
|
429
|
+
// instance via no key (e.g. jira_get_boards), it falls back to the default
|
|
430
|
+
// instance — the user can still override with args.instance.
|
|
431
|
+
function resolveInstanceForTool(toolName, args) {
|
|
432
|
+
if (!args) return defaultInstance;
|
|
433
|
+
if (args.instance) return getInstanceByName(args.instance);
|
|
434
|
+
const keyLike =
|
|
435
|
+
args.issueKey ||
|
|
436
|
+
args.parentKey ||
|
|
437
|
+
args.inwardIssueKey ||
|
|
438
|
+
args.outwardIssueKey;
|
|
439
|
+
if (keyLike) return getInstanceForKey(keyLike);
|
|
440
|
+
const projectLike = args.projectKey || args.spaceKey;
|
|
441
|
+
if (projectLike) return getInstanceForProject(projectLike);
|
|
442
|
+
return defaultInstance;
|
|
443
|
+
}
|
|
444
|
+
|
|
137
445
|
// ============ JIRA FUNCTIONS ============
|
|
138
446
|
|
|
139
447
|
async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
|
|
140
448
|
const { method = "GET", body } = options;
|
|
449
|
+
if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
|
|
450
|
+
recordDryRunCall({ api: "jira", method, endpoint, body, instance: instance.name });
|
|
451
|
+
}
|
|
141
452
|
const headers = {
|
|
142
453
|
Authorization: `Basic ${instance.auth}`,
|
|
143
454
|
Accept: "application/json",
|
|
@@ -162,6 +473,9 @@ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
|
|
|
162
473
|
|
|
163
474
|
async function fetchJiraAgile(endpoint, options = {}, instance = defaultInstance) {
|
|
164
475
|
const { method = "GET", body } = options;
|
|
476
|
+
if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
|
|
477
|
+
recordDryRunCall({ api: "jira-agile", method, endpoint, body, instance: instance.name });
|
|
478
|
+
}
|
|
165
479
|
const headers = {
|
|
166
480
|
Authorization: `Basic ${instance.auth}`,
|
|
167
481
|
Accept: "application/json",
|
|
@@ -307,6 +621,15 @@ function getConfluenceBaseUrl(instance) {
|
|
|
307
621
|
|
|
308
622
|
async function fetchConfluence(endpoint, options = {}, instance = defaultInstance) {
|
|
309
623
|
const { method = "GET", body, rawBody, contentType, extraHeaders } = options;
|
|
624
|
+
if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
|
|
625
|
+
recordDryRunCall({
|
|
626
|
+
api: "confluence",
|
|
627
|
+
method,
|
|
628
|
+
endpoint,
|
|
629
|
+
body: body !== undefined ? body : rawBody !== undefined ? "<raw body>" : undefined,
|
|
630
|
+
instance: instance.name,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
310
633
|
const headers = {
|
|
311
634
|
Authorization: `Basic ${instance.auth}`,
|
|
312
635
|
Accept: "application/json",
|
|
@@ -1667,9 +1990,31 @@ const server = new Server(
|
|
|
1667
1990
|
{ capabilities: { tools: {} } },
|
|
1668
1991
|
);
|
|
1669
1992
|
|
|
1670
|
-
|
|
1993
|
+
// Inject `dryRun` into the schema of any mutating tool so MCP clients with
|
|
1994
|
+
// strict schema validation accept it as a per-call argument.
|
|
1995
|
+
function _injectDryRunSchema(tool) {
|
|
1996
|
+
const meta = TOOL_METADATA[tool.name];
|
|
1997
|
+
if (!meta || meta.op === "read") return tool;
|
|
1998
|
+
const schema = tool.inputSchema || {};
|
|
1999
|
+
const props = schema.properties || {};
|
|
2000
|
+
if (props.dryRun) return tool;
|
|
1671
2001
|
return {
|
|
1672
|
-
|
|
2002
|
+
...tool,
|
|
2003
|
+
inputSchema: {
|
|
2004
|
+
...schema,
|
|
2005
|
+
properties: {
|
|
2006
|
+
...props,
|
|
2007
|
+
dryRun: {
|
|
2008
|
+
type: "boolean",
|
|
2009
|
+
description: "If true, return the HTTP request that would be sent (method, endpoint, body) without calling Atlassian. Multi-step tools only show the first write.",
|
|
2010
|
+
},
|
|
2011
|
+
},
|
|
2012
|
+
},
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
2017
|
+
const rawTools = [
|
|
1673
2018
|
{
|
|
1674
2019
|
name: "jira_get_myself",
|
|
1675
2020
|
description:
|
|
@@ -2924,14 +3269,126 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2924
3269
|
required: ["pageId", "filename"],
|
|
2925
3270
|
},
|
|
2926
3271
|
},
|
|
2927
|
-
]
|
|
2928
|
-
};
|
|
3272
|
+
];
|
|
3273
|
+
return { tools: rawTools.map(_injectDryRunSchema) };
|
|
2929
3274
|
});
|
|
2930
3275
|
|
|
3276
|
+
async function runWithPolicy(name, args, handler) {
|
|
3277
|
+
const meta = TOOL_METADATA[name];
|
|
3278
|
+
const inst = resolveInstanceForTool(name, args);
|
|
3279
|
+
|
|
3280
|
+
// 1. Scope check
|
|
3281
|
+
const scopeResult = checkScope(name, inst);
|
|
3282
|
+
if (!scopeResult.allowed) {
|
|
3283
|
+
if (meta && meta.op !== "read") {
|
|
3284
|
+
audit({
|
|
3285
|
+
tool: name,
|
|
3286
|
+
instance: inst.name,
|
|
3287
|
+
target: extractTarget(args),
|
|
3288
|
+
args: scrubArgs(args),
|
|
3289
|
+
success: false,
|
|
3290
|
+
deniedBy: "scope",
|
|
3291
|
+
reason: scopeResult.reason,
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
return {
|
|
3295
|
+
content: [{ type: "text", text: `Scope denied: ${scopeResult.reason}` }],
|
|
3296
|
+
isError: true,
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// 2. Resolve dry-run intent
|
|
3301
|
+
const dryRun = resolveDryRun(name, args, inst);
|
|
3302
|
+
|
|
3303
|
+
// 3. Rate limit — real writes only (dry-runs do not consume quota)
|
|
3304
|
+
if (!dryRun && meta && (meta.op === "write" || meta.op === "destructive")) {
|
|
3305
|
+
const rl = checkRateLimit(name, inst);
|
|
3306
|
+
if (!rl.allowed) {
|
|
3307
|
+
audit({
|
|
3308
|
+
tool: name,
|
|
3309
|
+
instance: inst.name,
|
|
3310
|
+
target: extractTarget(args),
|
|
3311
|
+
args: scrubArgs(args),
|
|
3312
|
+
success: false,
|
|
3313
|
+
deniedBy: "rate-limit",
|
|
3314
|
+
bucket: rl.bucket,
|
|
3315
|
+
retryAfter: rl.retryAfter,
|
|
3316
|
+
});
|
|
3317
|
+
return {
|
|
3318
|
+
content: [{
|
|
3319
|
+
type: "text",
|
|
3320
|
+
text: `Rate limit exceeded (bucket: ${rl.bucket}). Retry after ${rl.retryAfter}s.`,
|
|
3321
|
+
}],
|
|
3322
|
+
isError: true,
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// 4. Execute under policy context
|
|
3328
|
+
_policyCtx.dryRun = dryRun;
|
|
3329
|
+
_policyCtx.plan = dryRun ? [] : null;
|
|
3330
|
+
try {
|
|
3331
|
+
const result = await handler();
|
|
3332
|
+
if (meta && meta.op !== "read") {
|
|
3333
|
+
audit({
|
|
3334
|
+
tool: name,
|
|
3335
|
+
instance: inst.name,
|
|
3336
|
+
target: extractTarget(args),
|
|
3337
|
+
args: scrubArgs(args),
|
|
3338
|
+
dryRun,
|
|
3339
|
+
success: true,
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
return result;
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
if (error === DRY_RUN_ABORT) {
|
|
3345
|
+
const plan = _policyCtx.plan || [];
|
|
3346
|
+
audit({
|
|
3347
|
+
tool: name,
|
|
3348
|
+
instance: inst.name,
|
|
3349
|
+
target: extractTarget(args),
|
|
3350
|
+
args: scrubArgs(args),
|
|
3351
|
+
dryRun: true,
|
|
3352
|
+
success: true,
|
|
3353
|
+
status: "dry-run",
|
|
3354
|
+
plan,
|
|
3355
|
+
});
|
|
3356
|
+
return {
|
|
3357
|
+
content: [{
|
|
3358
|
+
type: "text",
|
|
3359
|
+
text: JSON.stringify({
|
|
3360
|
+
dryRun: true,
|
|
3361
|
+
note: "Plan shows the first HTTP write only. Multi-step tools (e.g. jira_transition with intermediate steps) execute the remaining writes when run for real.",
|
|
3362
|
+
plan,
|
|
3363
|
+
}, null, 2),
|
|
3364
|
+
}],
|
|
3365
|
+
};
|
|
3366
|
+
}
|
|
3367
|
+
if (meta && meta.op !== "read") {
|
|
3368
|
+
audit({
|
|
3369
|
+
tool: name,
|
|
3370
|
+
instance: inst.name,
|
|
3371
|
+
target: extractTarget(args),
|
|
3372
|
+
args: scrubArgs(args),
|
|
3373
|
+
dryRun,
|
|
3374
|
+
success: false,
|
|
3375
|
+
error: error.message,
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
return {
|
|
3379
|
+
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
3380
|
+
isError: true,
|
|
3381
|
+
};
|
|
3382
|
+
} finally {
|
|
3383
|
+
_policyCtx.dryRun = false;
|
|
3384
|
+
_policyCtx.plan = null;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
2931
3388
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2932
3389
|
const { name, arguments: args } = request.params;
|
|
2933
3390
|
|
|
2934
|
-
|
|
3391
|
+
return runWithPolicy(name, args, async () => {
|
|
2935
3392
|
if (name === "jira_get_myself") {
|
|
2936
3393
|
const inst = getInstanceByName(args.instance);
|
|
2937
3394
|
const result = await fetchJira("/myself", {}, inst);
|
|
@@ -3488,11 +3945,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3488
3945
|
}
|
|
3489
3946
|
}
|
|
3490
3947
|
|
|
3491
|
-
// Save without the computed auth field
|
|
3492
|
-
|
|
3948
|
+
// Save without the computed auth field. Preserve any safety/observability
|
|
3949
|
+
// fields the user has configured on the existing instance (scopes, dryRun,
|
|
3950
|
+
// confluenceBaseUrl) so they survive a re-run of jira_add_instance.
|
|
3951
|
+
const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
|
|
3952
|
+
const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
|
|
3953
|
+
const toSave = {
|
|
3954
|
+
...prevSaved,
|
|
3955
|
+
name: instName,
|
|
3956
|
+
email,
|
|
3957
|
+
token,
|
|
3958
|
+
baseUrl,
|
|
3959
|
+
projects,
|
|
3960
|
+
};
|
|
3493
3961
|
if (defaultTeam) toSave.defaultTeam = defaultTeam;
|
|
3494
3962
|
if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
|
|
3495
|
-
const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
|
|
3496
3963
|
if (savedIdx >= 0) {
|
|
3497
3964
|
savedConfig.instances[savedIdx] = toSave;
|
|
3498
3965
|
} else {
|
|
@@ -3828,6 +4295,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3828
4295
|
Buffer.from(footer),
|
|
3829
4296
|
]);
|
|
3830
4297
|
|
|
4298
|
+
if (_policyCtx.dryRun) {
|
|
4299
|
+
recordDryRunCall({
|
|
4300
|
+
api: "jira",
|
|
4301
|
+
method: "POST",
|
|
4302
|
+
endpoint: `/issue/${args.issueKey}/attachments`,
|
|
4303
|
+
body: `<multipart upload: ${fileName}, ${fileBuffer.length} bytes>`,
|
|
4304
|
+
instance: inst.name,
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
|
|
3831
4308
|
const response = await fetch(
|
|
3832
4309
|
`${inst.baseUrl}/rest/api/3/issue/${args.issueKey}/attachments`,
|
|
3833
4310
|
{
|
|
@@ -4718,6 +5195,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4718
5195
|
const bodyBuffer = Buffer.concat(parts);
|
|
4719
5196
|
|
|
4720
5197
|
const url = `${getConfluenceBaseUrl(inst)}/rest/api/content/${encodeURIComponent(args.pageId)}/child/attachment`;
|
|
5198
|
+
if (_policyCtx.dryRun) {
|
|
5199
|
+
recordDryRunCall({
|
|
5200
|
+
api: "confluence",
|
|
5201
|
+
method: "POST",
|
|
5202
|
+
endpoint: `/rest/api/content/${args.pageId}/child/attachment`,
|
|
5203
|
+
body: `<multipart upload: ${fileName}, ${fileBuffer.length} bytes>`,
|
|
5204
|
+
instance: inst.name,
|
|
5205
|
+
});
|
|
5206
|
+
}
|
|
4721
5207
|
const response = await fetch(url, {
|
|
4722
5208
|
method: "POST",
|
|
4723
5209
|
headers: {
|
|
@@ -4755,12 +5241,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4755
5241
|
} else {
|
|
4756
5242
|
throw new Error(`Unknown tool: ${name}`);
|
|
4757
5243
|
}
|
|
4758
|
-
}
|
|
4759
|
-
return {
|
|
4760
|
-
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
4761
|
-
isError: true,
|
|
4762
|
-
};
|
|
4763
|
-
}
|
|
5244
|
+
});
|
|
4764
5245
|
});
|
|
4765
5246
|
|
|
4766
5247
|
async function main() {
|
|
@@ -4774,5 +5255,14 @@ if (require.main === module) {
|
|
|
4774
5255
|
|
|
4775
5256
|
// Export for testing
|
|
4776
5257
|
if (typeof module !== "undefined") {
|
|
4777
|
-
module.exports = {
|
|
5258
|
+
module.exports = {
|
|
5259
|
+
buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys,
|
|
5260
|
+
resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql,
|
|
5261
|
+
fetchConfluence, getConfluenceBaseUrl, textToConfluenceStorage, htmlEscape,
|
|
5262
|
+
// Policy module exports
|
|
5263
|
+
TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
|
|
5264
|
+
checkRateLimit, _setClockForTests, _resetBucketsForTests,
|
|
5265
|
+
resolveInstanceForTool, _injectDryRunSchema,
|
|
5266
|
+
AUDIT_LOG_PATH,
|
|
5267
|
+
};
|
|
4778
5268
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rui.branco/jira-mcp",
|
|
3
|
-
"version": "1.7.
|
|
4
|
-
"description": "Jira MCP server for Claude Code - fetch tickets, search with JQL, update tickets, manage comments, change status,
|
|
3
|
+
"version": "1.7.6",
|
|
4
|
+
"description": "Jira & Confluence MCP server for any MCP-compatible AI client (Claude Code, Codex, Antigravity, Cursor, etc.) - fetch tickets, search with JQL, update tickets, manage comments, change status, get Figma designs. Optional per-tool scopes, dry-run, audit log, and rate limiting.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"jira-mcp": "./index.js"
|