jira-pilot 2.1.3 → 2.2.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.
@@ -1,18 +1,54 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ ListPromptsRequestSchema,
7
+ GetPromptRequestSchema,
8
+ ListResourceTemplatesRequestSchema,
9
+ ListResourcesRequestSchema,
10
+ ReadResourceRequestSchema,
11
+ } from "@modelcontextprotocol/sdk/types.js";
4
12
  import { api } from "../services/api-service.js";
5
13
  import { textToADF } from "../utils/text-to-adf.js";
14
+ import { readFileSync, existsSync } from "fs";
15
+ import { join, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { API } from "../utils/api-paths.js";
18
+
19
+ // Load package.json for version
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ function getPackageVersion() {
23
+ const candidates = [
24
+ join(__dirname, "../../package.json"),
25
+ join(__dirname, "../../../package.json"),
26
+ join(process.cwd(), "package.json"),
27
+ ];
28
+
29
+ for (const p of candidates) {
30
+ if (!existsSync(p)) continue;
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
33
+ if (typeof pkg.version === "string" && pkg.version) return pkg.version;
34
+ } catch { /* ignore */ }
35
+ }
36
+ return "0.0.0";
37
+ }
38
+
39
+ const version = getPackageVersion();
6
40
 
7
41
  // Initialize MCP Server
8
42
  const server = new Server(
9
43
  {
10
44
  name: "jira-pilot",
11
- version: "1.0.0",
45
+ version: version,
12
46
  },
13
47
  {
14
48
  capabilities: {
15
49
  tools: {},
50
+ prompts: { listChanged: true },
51
+ resources: { subscribe: false, listChanged: true },
16
52
  },
17
53
  }
18
54
  );
@@ -200,6 +236,175 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
200
236
  };
201
237
  });
202
238
 
239
+ // ── Prompt Definitions ────────────────────────────────────────────────
240
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
241
+ return {
242
+ prompts: [
243
+ {
244
+ name: "jira-assist",
245
+ description: "A system prompt to help the LLM understand how to assist with Jira tasks.",
246
+ },
247
+ {
248
+ name: "jira-summarize-issue",
249
+ description: "Summarize a specific Jira issue.",
250
+ arguments: [
251
+ {
252
+ name: "issueKey",
253
+ description: "The key of the issue to summarize (e.g., PROJ-123)",
254
+ required: true
255
+ }
256
+ ]
257
+ }
258
+ ]
259
+ };
260
+ });
261
+
262
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
263
+ const { name, arguments: args } = request.params;
264
+
265
+ if (name === "jira-assist") {
266
+ return {
267
+ messages: [
268
+ {
269
+ role: "user",
270
+ content: {
271
+ type: "text",
272
+ text: `You are Jira Pilot, an intelligent assistant for Jira.
273
+ Your goal is to help users manage their projects, issues, and workflows efficiently.
274
+
275
+ Available Tools:
276
+ - Use 'jira_list_issues' to find issues.
277
+ - Use 'jira_get_issue' to see details.
278
+ - Use 'jira_create_issue', 'jira_update_issue', 'jira_transition_issue' to modify.
279
+
280
+ Guidelines:
281
+ 1. Always be concise and helpful.
282
+ 2. If the user asks to "fix" something, look for relevant issues first.
283
+ 3. When creating issues, ask for clarification if fields are missing (Project, Type).
284
+ 4. Use JQL for powerful searching.`
285
+ }
286
+ }
287
+ ]
288
+ };
289
+ }
290
+
291
+ if (name === "jira-summarize-issue") {
292
+ const issueKey = args?.issueKey;
293
+ if (!issueKey) {
294
+ throw new Error("Missing required argument: issueKey");
295
+ }
296
+
297
+ return {
298
+ messages: [
299
+ {
300
+ role: "user",
301
+ content: {
302
+ type: "text",
303
+ text: `Please fetch details for Jira issue ${issueKey} using 'jira_get_issue', and then provide a concise summary of its status, priority, and recent activity.`
304
+ }
305
+ }
306
+ ]
307
+ };
308
+ }
309
+
310
+ throw new Error(`Prompt not found: ${name}. Available: jira-assist, jira-summarize-issue`);
311
+ });
312
+
313
+ // ── Resource Templates ──────────────────────────────────────────────
314
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
315
+ return {
316
+ resourceTemplates: []
317
+ };
318
+ });
319
+
320
+ // ── Resource Definitions ──────────────────────────────────────────────
321
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
322
+ return {
323
+ resources: [
324
+ {
325
+ uri: "jira://myself",
326
+ name: "My Profile",
327
+ description: "Details of the currently authenticated user.",
328
+ mimeType: "application/json"
329
+ },
330
+ {
331
+ uri: "jira://projects",
332
+ name: "All Projects",
333
+ description: "List of all accessible Jira projects.",
334
+ mimeType: "application/json"
335
+ }
336
+ ]
337
+ };
338
+ });
339
+
340
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
341
+ const { uri } = request.params;
342
+
343
+ const createEnvelope = (type: string, data: any) => ({
344
+ source: "jira-pilot",
345
+ type,
346
+ data,
347
+ fetchedAt: new Date().toISOString()
348
+ });
349
+
350
+ try {
351
+ if (uri === "jira://myself") {
352
+ const myself = await api.get(API.USER.MYSELF);
353
+ // Mask sensitive data if needed, though 'myself' usually implies permission to see own data.
354
+ // keeping it simple for now, but ensuring consistent shape.
355
+ const safeData = {
356
+ accountId: myself.accountId,
357
+ displayName: myself.displayName,
358
+ active: myself.active,
359
+ timeZone: myself.timeZone,
360
+ // Only include email if present, or maybe mask it? User asked to be careful.
361
+ // We'll exclude email to be safe as per user request "do not include email".
362
+ };
363
+
364
+ return {
365
+ contents: [{
366
+ uri,
367
+ mimeType: "application/json",
368
+ text: JSON.stringify(createEnvelope("myself", safeData), null, 2)
369
+ }]
370
+ };
371
+ }
372
+
373
+ if (uri === "jira://projects") {
374
+ const data = await api.get(`${API.PROJECT.SEARCH}?maxResults=50`);
375
+ const projects = (data.values || []).map((p: any) => ({
376
+ key: p.key,
377
+ name: p.name,
378
+ id: p.id,
379
+ style: p.style
380
+ }));
381
+
382
+ return {
383
+ contents: [{
384
+ uri,
385
+ mimeType: "application/json",
386
+ text: JSON.stringify(createEnvelope("projects", projects), null, 2)
387
+ }]
388
+ };
389
+ }
390
+
391
+ throw new Error(`Resource not found: ${uri}. Available: jira://myself, jira://projects`);
392
+
393
+ } catch (e: any) {
394
+ // Handle Auth/Network errors specifically
395
+ if (e.response?.status === 401 || e.response?.status === 403) {
396
+ throw new Error(`Jira auth is missing or expired. Run 'jira config setup' to authenticate.`);
397
+ }
398
+ if (e.message.includes("Resource not found")) {
399
+ throw e; // Re-throw 404s we generated
400
+ }
401
+
402
+ // Upstream errors
403
+ const status = e.response?.status || "Unknown";
404
+ throw new Error(`Upstream Jira error (${status}): ${e.message}`);
405
+ }
406
+ });
407
+
203
408
  // ── Tool Handlers ────────────────────────────────────────────────────
204
409
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
205
410
  const { name, arguments: args } = request.params as { name: string; arguments: any };
@@ -209,7 +414,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
209
414
  if (name === "jira_list_issues") {
210
415
  const jql = args.jql || "";
211
416
  const limit = args.limit || 10;
212
- const data = await api.post('/search/jql', {
417
+ const data = await api.post(API.SEARCH.JQL, {
213
418
  jql,
214
419
  maxResults: limit,
215
420
  fields: ['summary', 'status', 'assignee', 'priority', 'created', 'updated']
@@ -233,7 +438,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
233
438
 
234
439
  // ── jira_get_issue ──────────────────────────────────
235
440
  if (name === "jira_get_issue") {
236
- const data = await api.get(`/issue/${args.issueKey}`);
441
+ const data = await api.get(API.ISSUE.GET(args.issueKey));
237
442
 
238
443
  // Return a cleaner summary for agents
239
444
  const result = {
@@ -284,7 +489,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
284
489
  body.fields.assignee = { accountId: args.assigneeId };
285
490
  }
286
491
 
287
- const data = await api.post('/issue', body);
492
+ const data = await api.post(API.ISSUE.BASE, body);
288
493
  return {
289
494
  content: [{ type: "text", text: JSON.stringify({ key: data.key, self: data.self }, null, 2) }]
290
495
  };
@@ -294,8 +499,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
294
499
  if (name === "jira_transition_issue") {
295
500
  if (!args.transitionId) {
296
501
  // List available transitions
297
- const transData = await api.get(`/issue/${args.issueKey}/transitions`);
298
- const issue = await api.get(`/issue/${args.issueKey}?fields=summary,status`);
502
+ const transData = await api.get(API.ISSUE.TRANSITIONS(args.issueKey));
503
+ const issue = await api.get(`${API.ISSUE.GET(args.issueKey)}?fields=summary,status`);
299
504
 
300
505
  const result = {
301
506
  issueKey: args.issueKey,
@@ -314,7 +519,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
314
519
  }
315
520
 
316
521
  // Execute transition
317
- await api.post(`/issue/${args.issueKey}/transitions`, {
522
+ await api.post(API.ISSUE.TRANSITIONS(args.issueKey), {
318
523
  transition: { id: args.transitionId }
319
524
  });
320
525
 
@@ -329,11 +534,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
329
534
 
330
535
  // Resolve "me" to actual account ID
331
536
  if (accountId === 'me') {
332
- const myself = await api.get('/myself');
537
+ const myself = await api.get(API.USER.MYSELF);
333
538
  accountId = myself.accountId;
334
539
  }
335
540
 
336
- await api.put(`/issue/${args.issueKey}/assignee`, {
541
+ await api.put(API.ISSUE.ASSIGNEE(args.issueKey), {
337
542
  accountId: accountId || null
338
543
  });
339
544
 
@@ -344,7 +549,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
344
549
 
345
550
  // ── jira_add_comment ────────────────────────────────
346
551
  if (name === "jira_add_comment") {
347
- const data = await api.post(`/issue/${args.issueKey}/comment`, {
552
+ const data = await api.post(API.ISSUE.COMMENT(args.issueKey), {
348
553
  body: textToADF(args.body)
349
554
  });
350
555
 
@@ -364,7 +569,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
364
569
  if (args.assigneeId) {
365
570
  let accId = args.assigneeId;
366
571
  if (accId === 'me') {
367
- const myself = await api.get('/myself');
572
+ const myself = await api.get(API.USER.MYSELF);
368
573
  accId = myself.accountId;
369
574
  } else if (accId === 'none') {
370
575
  accId = null;
@@ -379,7 +584,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
379
584
  };
380
585
  }
381
586
 
382
- await api.put(`/issue/${args.issueKey}`, updateBody);
587
+ await api.put(API.ISSUE.GET(args.issueKey), updateBody);
383
588
 
384
589
  return {
385
590
  content: [{ type: "text", text: JSON.stringify({ success: true, issueKey: args.issueKey }) }]
@@ -388,12 +593,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
388
593
 
389
594
  // ── jira_search_users ───────────────────────────────
390
595
  if (name === "jira_search_users") {
391
- const users = await api.get(`/user/search?query=${encodeURIComponent(args.query)}`);
596
+ const users = await api.get(`${API.USER.SEARCH}?query=${encodeURIComponent(args.query)}`);
392
597
 
393
598
  const results = (users || []).map((u: any) => ({
394
599
  accountId: u.accountId,
395
600
  displayName: u.displayName,
396
- email: u.emailAddress,
601
+ // Email excluded for safety
602
+ // email: u.emailAddress,
397
603
  active: u.active
398
604
  }));
399
605
 
@@ -404,11 +610,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
404
610
 
405
611
  // ── jira_myself ─────────────────────────────────────
406
612
  if (name === "jira_myself") {
407
- const myself = await api.get('/myself');
613
+ const myself = await api.get(API.USER.MYSELF);
408
614
  const result = {
409
615
  accountId: myself.accountId,
410
616
  displayName: myself.displayName,
411
- email: myself.emailAddress,
617
+ // Email excluded for safety
618
+ // email: myself.emailAddress,
412
619
  active: myself.active,
413
620
  timeZone: myself.timeZone
414
621
  };
@@ -421,7 +628,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
421
628
  // ── jira_list_projects ──────────────────────────────
422
629
  if (name === "jira_list_projects") {
423
630
  const limit = args.limit || 50;
424
- const data = await api.get(`/project/search?maxResults=${limit}`);
631
+ const data = await api.get(`${API.PROJECT.SEARCH}?maxResults=${limit}`);
425
632
 
426
633
  const projects = (data.values || []).map((p: any) => ({
427
634
  key: p.key,
@@ -464,7 +671,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
464
671
  body.comment = textToADF(args.comment);
465
672
  }
466
673
 
467
- await api.post(`/issue/${args.issueKey}/worklog`, body);
674
+ await api.post(API.ISSUE.WORKLOG(args.issueKey), body);
468
675
 
469
676
  return {
470
677
  content: [{ type: "text", text: JSON.stringify({ success: true, issueKey: args.issueKey, timeSpent: args.timeSpent }) }]
@@ -474,7 +681,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
474
681
  // ── jira_create_subtask ─────────────────────────────
475
682
  if (name === "jira_create_subtask") {
476
683
  // 1. Fetch parent to get project
477
- const parent = await api.get(`/issue/${args.parentKey}?fields=project`);
684
+ const parent = await api.get(`${API.ISSUE.GET(args.parentKey)}?fields=project`);
478
685
  const projectKey = parent.fields.project.key;
479
686
 
480
687
  // 2. Find subtask issue type
@@ -504,13 +711,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
504
711
  if (args.assigneeId) {
505
712
  let accId = args.assigneeId;
506
713
  if (accId === 'me') {
507
- const myself = await api.get('/myself');
714
+ const myself = await api.get(API.USER.MYSELF);
508
715
  accId = myself.accountId;
509
716
  }
510
717
  body.fields.assignee = { accountId: accId };
511
718
  }
512
719
 
513
- const data = await api.post('/issue', body);
720
+ const data = await api.post(API.ISSUE.BASE, body);
514
721
  return {
515
722
  content: [{ type: "text", text: JSON.stringify({ key: data.key, self: data.self }, null, 2) }]
516
723
  };
@@ -520,15 +727,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
520
727
  if (name === "jira_add_attachment") {
521
728
  try {
522
729
  // Dynamically import fs/path to avoid top-level node dependencies if this runs in browser-like env (unlikely but safe)
523
- const { openAsBlob } = await import('node:fs');
524
- const path = await import('node:path');
730
+ const fs = await import("node:fs");
731
+ const path = await import("node:path");
525
732
 
526
733
  const filePath = args.filePath;
527
- const file = await openAsBlob(filePath);
734
+ const file = await fs.openAsBlob(filePath);
528
735
  const formData = new FormData();
529
- formData.append('file', file, path.default.basename(filePath));
736
+ formData.append("file", file, path.basename(filePath));
530
737
 
531
- const result = await api.upload(`/issue/${args.issueKey}/attachments`, formData);
738
+ const result = await api.upload(API.ISSUE.ATTACHMENTS(args.issueKey), formData);
532
739
 
533
740
  return {
534
741
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -1,6 +1,7 @@
1
1
  import { HttpClient } from '../utils/http.js';
2
2
  import chalk from 'chalk';
3
3
  import { getCredentials } from '../utils/config.js';
4
+ import { API } from '../utils/api-paths.js';
4
5
 
5
6
  export class ApiService {
6
7
  private client: any;
@@ -101,14 +102,18 @@ export class ApiService {
101
102
  return response.data;
102
103
  }
103
104
 
104
- async search(jql: string, startAt: number = 0, maxResults: number = 50) {
105
- return this.post('/search/jql', {
105
+ async search(jql: string, startAt: number = 0, maxResults: number = 50, nextPageToken?: string) {
106
+ const payload: any = {
106
107
  jql,
107
- startAt,
108
108
  maxResults,
109
- fields: ['summary', 'status', 'assignee', 'priority', 'issuetype', 'created', 'updated', 'project'],
110
- validation: 'warn'
111
- });
109
+ fields: ['summary', 'status', 'assignee', 'priority', 'issuetype', 'created', 'updated', 'project']
110
+ };
111
+
112
+ if (nextPageToken) {
113
+ payload.nextPageToken = nextPageToken;
114
+ }
115
+
116
+ return this.post(API.SEARCH.JQL, payload);
112
117
  }
113
118
 
114
119
  async upload(url: string, formData: any) {
@@ -0,0 +1,32 @@
1
+
2
+ export const API = {
3
+ SEARCH: {
4
+ JQL: '/search/jql',
5
+ },
6
+ ISSUE: {
7
+ BASE: '/issue',
8
+ GET: (key: string) => `/issue/${key}`,
9
+ TRANSITIONS: (key: string) => `/issue/${key}/transitions`,
10
+ COMMENT: (key: string) => `/issue/${key}/comment`,
11
+ ASSIGNEE: (key: string) => `/issue/${key}/assignee`,
12
+ WORKLOG: (key: string) => `/issue/${key}/worklog`,
13
+ ATTACHMENTS: (key: string) => `/issue/${key}/attachments`,
14
+ CREATEMETA: (projectKey: string) => `/issue/createmeta/${projectKey}/issuetypes`,
15
+ },
16
+ PROJECT: {
17
+ SEARCH: '/project/search',
18
+ GET: (key: string) => `/project/${key}`,
19
+ COMPONENTS: (key: string) => `/project/${key}/components`,
20
+ VERSIONS: (key: string) => `/project/${key}/versions`,
21
+ },
22
+ USER: {
23
+ MYSELF: '/myself',
24
+ SEARCH: '/user/search',
25
+ },
26
+ PRIORITY: {
27
+ ALL: '/priority',
28
+ },
29
+ SERVER: {
30
+ INFO: '/serverInfo',
31
+ }
32
+ };