pnd-jira-mcp 1.0.0__py3-none-any.whl

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.
pnd_jira_mcp/server.py ADDED
@@ -0,0 +1,392 @@
1
+ """
2
+ Jira MCP Server Module
3
+
4
+ A standalone MCP (Model Context Protocol) server for Jira integration.
5
+ Can be installed via pip and used independently.
6
+
7
+ Supported MCP Commands:
8
+ - add_comment: Add a comment to a Jira issue
9
+ - transition_issue: Transition an issue to a new status
10
+ - update_fields: Update fields on an issue
11
+ - update_ai_fields: Update AI-related custom fields
12
+ - get_issue: Get issue details
13
+ - get_transitions: Get available transitions
14
+ - search_issues: Search issues using JQL
15
+ - add_label: Add a label to an issue
16
+ - test_connection: Test Jira connection
17
+ - get_boards: Get Jira boards
18
+ - get_sprints: Get sprints for a board
19
+ - get_sprint: Get sprint details
20
+ - get_active_sprint: Get active sprint for a board
21
+ - get_sprint_issues: Get issues in a sprint
22
+ """
23
+
24
+ import json
25
+ import logging
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ from mcp import types
29
+ from mcp.server import Server
30
+
31
+ from .client import JiraClient, JiraConfig
32
+ from .tools import get_jira_tools
33
+
34
+ logger = logging.getLogger("pnd_jira_mcp")
35
+
36
+
37
+ async def handle_jira_tool(
38
+ name: str,
39
+ arguments: Dict[str, Any],
40
+ jira_client: Optional[JiraClient] = None
41
+ ) -> List[types.TextContent]:
42
+ """
43
+ Handle Jira tool calls.
44
+
45
+ Args:
46
+ name: Tool name
47
+ arguments: Tool arguments
48
+ jira_client: Optional pre-configured JiraClient instance
49
+
50
+ Returns:
51
+ List of TextContent with results
52
+ """
53
+ try:
54
+ client = jira_client or JiraClient()
55
+
56
+ if name == "add_comment":
57
+ result_data = client.add_comment(
58
+ issue_key=arguments["issue_key"],
59
+ body=arguments["body"],
60
+ add_qain_label=arguments.get("add_qain_label", True)
61
+ )
62
+ result = {
63
+ "status": "success",
64
+ "message": f"Comment added to {arguments['issue_key']}",
65
+ "comment_id": result_data.get("id")
66
+ }
67
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
68
+
69
+ elif name == "transition_issue":
70
+ success = client.transition_issue(
71
+ issue_key=arguments["issue_key"],
72
+ transition_id=arguments["transition_id"],
73
+ fields=arguments.get("fields"),
74
+ comment=arguments.get("comment")
75
+ )
76
+ result = {
77
+ "status": "success" if success else "failed",
78
+ "message": f"Issue {arguments['issue_key']} transitioned" if success else "Transition failed"
79
+ }
80
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
81
+
82
+ elif name == "update_fields":
83
+ success = client.update_fields(
84
+ issue_key=arguments["issue_key"],
85
+ fields=arguments["fields"]
86
+ )
87
+ result = {
88
+ "status": "success" if success else "failed",
89
+ "message": f"Fields updated on {arguments['issue_key']}" if success else "Update failed"
90
+ }
91
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
92
+
93
+ elif name == "update_ai_fields":
94
+ success = client.update_ai_fields(
95
+ issue_key=arguments["issue_key"],
96
+ ai_used=arguments.get("ai_used", True),
97
+ agent_name=arguments.get("agent_name"),
98
+ efficiency_score=arguments.get("efficiency_score"),
99
+ duration_ms=arguments.get("duration_ms")
100
+ )
101
+ result = {
102
+ "status": "success" if success else "failed",
103
+ "message": f"AI fields updated on {arguments['issue_key']}" if success else "Update failed"
104
+ }
105
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
106
+
107
+ elif name == "get_issue":
108
+ issue = client.get_issue(
109
+ issue_key=arguments["issue_key"],
110
+ fields=arguments.get("fields")
111
+ )
112
+ if issue:
113
+ result = {
114
+ "status": "success",
115
+ "issue": {
116
+ "key": issue.key,
117
+ "id": issue.id,
118
+ "summary": issue.summary,
119
+ "status": issue.status,
120
+ "issue_type": issue.issue_type,
121
+ "description": issue.description,
122
+ "assignee": issue.assignee,
123
+ "priority": issue.priority,
124
+ "labels": issue.labels
125
+ }
126
+ }
127
+ else:
128
+ result = {
129
+ "status": "not_found",
130
+ "message": f"Issue {arguments['issue_key']} not found"
131
+ }
132
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
133
+
134
+ elif name == "get_transitions":
135
+ transitions = client.get_transitions(arguments["issue_key"])
136
+ result = {
137
+ "status": "success",
138
+ "issue_key": arguments["issue_key"],
139
+ "transitions": [
140
+ {"id": t["id"], "name": t["name"]}
141
+ for t in transitions
142
+ ]
143
+ }
144
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
145
+
146
+ elif name == "search_issues":
147
+ issues = client.search_issues(
148
+ jql=arguments["jql"],
149
+ max_results=arguments.get("max_results", 50),
150
+ fields=arguments.get("fields")
151
+ )
152
+ result = {
153
+ "status": "success",
154
+ "count": len(issues),
155
+ "issues": [
156
+ {
157
+ "key": issue.key,
158
+ "summary": issue.summary,
159
+ "status": issue.status,
160
+ "issue_type": issue.issue_type
161
+ }
162
+ for issue in issues
163
+ ]
164
+ }
165
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
166
+
167
+ elif name == "add_label":
168
+ success = client.add_label(
169
+ issue_key=arguments["issue_key"],
170
+ label=arguments["label"]
171
+ )
172
+ result = {
173
+ "status": "success" if success else "failed",
174
+ "message": f"Label '{arguments['label']}' added to {arguments['issue_key']}" if success else "Failed to add label"
175
+ }
176
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
177
+
178
+ elif name == "test_connection":
179
+ success = client.test_connection()
180
+ result = {
181
+ "status": "success" if success else "failed",
182
+ "message": "Jira connection successful" if success else "Jira connection failed",
183
+ "config": client.config.to_dict() if success else None
184
+ }
185
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
186
+
187
+ elif name == "get_boards":
188
+ boards = client.get_boards(
189
+ project_key=arguments.get("project_key"),
190
+ board_type=arguments.get("board_type")
191
+ )
192
+ result = {
193
+ "status": "success",
194
+ "count": len(boards),
195
+ "boards": [
196
+ {
197
+ "id": board["id"],
198
+ "name": board["name"],
199
+ "type": board.get("type", "unknown")
200
+ }
201
+ for board in boards
202
+ ]
203
+ }
204
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
205
+
206
+ elif name == "get_sprints":
207
+ sprints = client.get_sprints(
208
+ board_id=arguments["board_id"],
209
+ state=arguments.get("state"),
210
+ max_results=arguments.get("max_results", 50)
211
+ )
212
+ result = {
213
+ "status": "success",
214
+ "count": len(sprints),
215
+ "sprints": [
216
+ {
217
+ "id": sprint["id"],
218
+ "name": sprint["name"],
219
+ "state": sprint["state"],
220
+ "startDate": sprint.get("startDate"),
221
+ "endDate": sprint.get("endDate"),
222
+ "goal": sprint.get("goal")
223
+ }
224
+ for sprint in sprints
225
+ ]
226
+ }
227
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
228
+
229
+ elif name == "get_sprint":
230
+ sprint = client.get_sprint(arguments["sprint_id"])
231
+ if sprint:
232
+ result = {
233
+ "status": "success",
234
+ "sprint": {
235
+ "id": sprint["id"],
236
+ "name": sprint["name"],
237
+ "state": sprint["state"],
238
+ "startDate": sprint.get("startDate"),
239
+ "endDate": sprint.get("endDate"),
240
+ "goal": sprint.get("goal"),
241
+ "originBoardId": sprint.get("originBoardId")
242
+ }
243
+ }
244
+ else:
245
+ result = {
246
+ "status": "not_found",
247
+ "message": f"Sprint {arguments['sprint_id']} not found"
248
+ }
249
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
250
+
251
+ elif name == "get_active_sprint":
252
+ sprint = client.get_active_sprint(arguments["board_id"])
253
+ if sprint:
254
+ result = {
255
+ "status": "success",
256
+ "sprint": {
257
+ "id": sprint["id"],
258
+ "name": sprint["name"],
259
+ "state": sprint["state"],
260
+ "startDate": sprint.get("startDate"),
261
+ "endDate": sprint.get("endDate"),
262
+ "goal": sprint.get("goal")
263
+ }
264
+ }
265
+ else:
266
+ result = {
267
+ "status": "not_found",
268
+ "message": f"No active sprint found for board {arguments['board_id']}"
269
+ }
270
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
271
+
272
+ elif name == "get_sprint_issues":
273
+ issues = client.get_sprint_issues(
274
+ sprint_id=arguments["sprint_id"],
275
+ fields=arguments.get("fields"),
276
+ max_results=arguments.get("max_results", 200)
277
+ )
278
+ result = {
279
+ "status": "success",
280
+ "count": len(issues),
281
+ "issues": [
282
+ {
283
+ "key": issue.key,
284
+ "summary": issue.summary,
285
+ "status": issue.status,
286
+ "issue_type": issue.issue_type,
287
+ "assignee": issue.assignee,
288
+ "priority": issue.priority
289
+ }
290
+ for issue in issues
291
+ ]
292
+ }
293
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
294
+
295
+ else:
296
+ return [types.TextContent(
297
+ type="text",
298
+ text=json.dumps({"status": "error", "message": f"Unknown tool: {name}"})
299
+ )]
300
+
301
+ except Exception as e:
302
+ logger.error(f"Error handling Jira tool {name}: {e}")
303
+ return [types.TextContent(
304
+ type="text",
305
+ text=json.dumps({"status": "error", "message": str(e)})
306
+ )]
307
+
308
+
309
+ class JiraMCPServer:
310
+ """
311
+ Standalone MCP server for Jira operations.
312
+
313
+ Can be run independently without any other dependencies.
314
+ Communicates via stdio for Claude Desktop compatibility.
315
+
316
+ Environment Variables:
317
+ JIRA_BASE_URL: Jira instance URL (e.g., https://your-domain.atlassian.net)
318
+ JIRA_EMAIL: Jira account email
319
+ JIRA_API_TOKEN: Jira API token
320
+ """
321
+
322
+ def __init__(
323
+ self,
324
+ name: str = "jira-mcp",
325
+ config: Optional[JiraConfig] = None
326
+ ):
327
+ """
328
+ Initialize the Jira MCP server.
329
+
330
+ Args:
331
+ name: Server name for MCP identification
332
+ config: Optional JiraConfig for custom configuration
333
+ """
334
+ self.server = Server(name=name)
335
+ self.config = config
336
+ self._jira_client: Optional[JiraClient] = None
337
+ self._register_tools()
338
+
339
+ @property
340
+ def jira_client(self) -> JiraClient:
341
+ """Get or create the Jira client."""
342
+ if self._jira_client is None:
343
+ self._jira_client = JiraClient(config=self.config)
344
+ return self._jira_client
345
+
346
+ def _register_tools(self):
347
+ """Register all Jira tools."""
348
+ jira_tools = get_jira_tools()
349
+
350
+ @self.server.list_tools()
351
+ async def list_tools() -> List[types.Tool]:
352
+ """List all Jira tools."""
353
+ return jira_tools
354
+
355
+ @self.server.call_tool()
356
+ async def call_tool(
357
+ name: str,
358
+ arguments: Dict[str, Any]
359
+ ) -> List[types.TextContent]:
360
+ """Handle Jira tool calls."""
361
+ return await handle_jira_tool(name, arguments, self.jira_client)
362
+
363
+ async def run(self):
364
+ """Run the MCP server via stdio."""
365
+ from mcp.server.stdio import stdio_server
366
+
367
+ async with stdio_server() as (read_stream, write_stream):
368
+ await self.server.run(
369
+ read_stream,
370
+ write_stream,
371
+ self.server.create_initialization_options()
372
+ )
373
+
374
+
375
+ async def main():
376
+ """Main entry point for standalone Jira MCP server."""
377
+ logging.basicConfig(
378
+ level=logging.INFO,
379
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
380
+ )
381
+ server = JiraMCPServer()
382
+ await server.run()
383
+
384
+
385
+ def main_sync():
386
+ """Synchronous entry point for CLI."""
387
+ import asyncio
388
+ asyncio.run(main())
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main_sync()
pnd_jira_mcp/tools.py ADDED
@@ -0,0 +1,295 @@
1
+ """
2
+ Jira MCP Tools Module
3
+
4
+ Defines the MCP tool specifications for Jira operations.
5
+ These tools are exposed via the Jira MCP server for Claude Desktop/Code integration.
6
+ """
7
+
8
+ from mcp import types
9
+ from typing import List
10
+
11
+
12
+ def get_jira_tools() -> List[types.Tool]:
13
+ """
14
+ Get the list of Jira MCP tools.
15
+
16
+ Returns:
17
+ List of MCP Tool definitions for Jira operations.
18
+ """
19
+ return [
20
+ types.Tool(
21
+ name="add_comment",
22
+ description="Add a comment to a Jira issue. Supports markdown formatting which is converted to Atlassian Document Format (ADF).",
23
+ inputSchema={
24
+ "type": "object",
25
+ "properties": {
26
+ "issue_key": {
27
+ "type": "string",
28
+ "description": "Jira issue key (e.g., 'PROJ-123')"
29
+ },
30
+ "body": {
31
+ "type": "string",
32
+ "description": "Comment body in markdown format"
33
+ },
34
+ "add_qain_label": {
35
+ "type": "boolean",
36
+ "description": "Whether to add the qAIn label to the issue (default: true)",
37
+ "default": True
38
+ }
39
+ },
40
+ "required": ["issue_key", "body"]
41
+ }
42
+ ),
43
+ types.Tool(
44
+ name="transition_issue",
45
+ description="Transition a Jira issue to a new status. Use get_transitions first to see available transitions.",
46
+ inputSchema={
47
+ "type": "object",
48
+ "properties": {
49
+ "issue_key": {
50
+ "type": "string",
51
+ "description": "Jira issue key (e.g., 'PROJ-123')"
52
+ },
53
+ "transition_id": {
54
+ "type": "string",
55
+ "description": "ID of the transition to perform (get from get_transitions)"
56
+ },
57
+ "comment": {
58
+ "type": "string",
59
+ "description": "Optional comment to add during transition"
60
+ },
61
+ "fields": {
62
+ "type": "object",
63
+ "description": "Optional fields to update during transition"
64
+ }
65
+ },
66
+ "required": ["issue_key", "transition_id"]
67
+ }
68
+ ),
69
+ types.Tool(
70
+ name="update_fields",
71
+ description="Update fields on a Jira issue. Supports both standard and custom fields.",
72
+ inputSchema={
73
+ "type": "object",
74
+ "properties": {
75
+ "issue_key": {
76
+ "type": "string",
77
+ "description": "Jira issue key (e.g., 'PROJ-123')"
78
+ },
79
+ "fields": {
80
+ "type": "object",
81
+ "description": "Dictionary of field names/IDs to values"
82
+ }
83
+ },
84
+ "required": ["issue_key", "fields"]
85
+ }
86
+ ),
87
+ types.Tool(
88
+ name="update_ai_fields",
89
+ description="Update AI-related custom fields on a Jira issue. Convenience method for updating AI tracking fields.",
90
+ inputSchema={
91
+ "type": "object",
92
+ "properties": {
93
+ "issue_key": {
94
+ "type": "string",
95
+ "description": "Jira issue key (e.g., 'PROJ-123')"
96
+ },
97
+ "ai_used": {
98
+ "type": "boolean",
99
+ "description": "Whether AI was used (default: true)",
100
+ "default": True
101
+ },
102
+ "agent_name": {
103
+ "type": "string",
104
+ "description": "Name of the AI agent"
105
+ },
106
+ "efficiency_score": {
107
+ "type": "number",
108
+ "description": "Effectiveness score (0-100)"
109
+ },
110
+ "duration_ms": {
111
+ "type": "number",
112
+ "description": "Duration in milliseconds"
113
+ }
114
+ },
115
+ "required": ["issue_key"]
116
+ }
117
+ ),
118
+ types.Tool(
119
+ name="get_issue",
120
+ description="Get details of a Jira issue by key.",
121
+ inputSchema={
122
+ "type": "object",
123
+ "properties": {
124
+ "issue_key": {
125
+ "type": "string",
126
+ "description": "Jira issue key (e.g., 'PROJ-123')"
127
+ },
128
+ "fields": {
129
+ "type": "array",
130
+ "items": {"type": "string"},
131
+ "description": "Optional list of specific fields to retrieve"
132
+ }
133
+ },
134
+ "required": ["issue_key"]
135
+ }
136
+ ),
137
+ types.Tool(
138
+ name="get_transitions",
139
+ description="Get available transitions for a Jira issue. Use this to find valid transition IDs before calling transition_issue.",
140
+ inputSchema={
141
+ "type": "object",
142
+ "properties": {
143
+ "issue_key": {
144
+ "type": "string",
145
+ "description": "Jira issue key (e.g., 'PROJ-123')"
146
+ }
147
+ },
148
+ "required": ["issue_key"]
149
+ }
150
+ ),
151
+ types.Tool(
152
+ name="search_issues",
153
+ description="Search for Jira issues using JQL (Jira Query Language).",
154
+ inputSchema={
155
+ "type": "object",
156
+ "properties": {
157
+ "jql": {
158
+ "type": "string",
159
+ "description": "JQL query string (e.g., 'project = PROJ AND status = Open')"
160
+ },
161
+ "max_results": {
162
+ "type": "integer",
163
+ "description": "Maximum number of results to return (default: 50)",
164
+ "default": 50
165
+ },
166
+ "fields": {
167
+ "type": "array",
168
+ "items": {"type": "string"},
169
+ "description": "Optional list of specific fields to retrieve"
170
+ }
171
+ },
172
+ "required": ["jql"]
173
+ }
174
+ ),
175
+ types.Tool(
176
+ name="add_label",
177
+ description="Add a label to a Jira issue.",
178
+ inputSchema={
179
+ "type": "object",
180
+ "properties": {
181
+ "issue_key": {
182
+ "type": "string",
183
+ "description": "Jira issue key (e.g., 'PROJ-123')"
184
+ },
185
+ "label": {
186
+ "type": "string",
187
+ "description": "Label to add to the issue"
188
+ }
189
+ },
190
+ "required": ["issue_key", "label"]
191
+ }
192
+ ),
193
+ types.Tool(
194
+ name="test_connection",
195
+ description="Test the Jira connection and verify credentials.",
196
+ inputSchema={
197
+ "type": "object",
198
+ "properties": {}
199
+ }
200
+ ),
201
+ types.Tool(
202
+ name="get_boards",
203
+ description="Get all Jira boards, optionally filtered by project or type.",
204
+ inputSchema={
205
+ "type": "object",
206
+ "properties": {
207
+ "project_key": {
208
+ "type": "string",
209
+ "description": "Optional project key to filter boards (e.g., 'PROJ')"
210
+ },
211
+ "board_type": {
212
+ "type": "string",
213
+ "description": "Optional board type filter",
214
+ "enum": ["scrum", "kanban"]
215
+ }
216
+ }
217
+ }
218
+ ),
219
+ types.Tool(
220
+ name="get_sprints",
221
+ description="Get sprints for a Jira board. Use get_boards first to find the board ID.",
222
+ inputSchema={
223
+ "type": "object",
224
+ "properties": {
225
+ "board_id": {
226
+ "type": "integer",
227
+ "description": "Board ID (get from get_boards)"
228
+ },
229
+ "state": {
230
+ "type": "string",
231
+ "description": "Optional sprint state filter",
232
+ "enum": ["active", "closed", "future"]
233
+ },
234
+ "max_results": {
235
+ "type": "integer",
236
+ "description": "Maximum number of results (default: 50)",
237
+ "default": 50
238
+ }
239
+ },
240
+ "required": ["board_id"]
241
+ }
242
+ ),
243
+ types.Tool(
244
+ name="get_sprint",
245
+ description="Get details of a specific sprint by ID.",
246
+ inputSchema={
247
+ "type": "object",
248
+ "properties": {
249
+ "sprint_id": {
250
+ "type": "integer",
251
+ "description": "Sprint ID"
252
+ }
253
+ },
254
+ "required": ["sprint_id"]
255
+ }
256
+ ),
257
+ types.Tool(
258
+ name="get_active_sprint",
259
+ description="Get the currently active sprint for a board.",
260
+ inputSchema={
261
+ "type": "object",
262
+ "properties": {
263
+ "board_id": {
264
+ "type": "integer",
265
+ "description": "Board ID (get from get_boards)"
266
+ }
267
+ },
268
+ "required": ["board_id"]
269
+ }
270
+ ),
271
+ types.Tool(
272
+ name="get_sprint_issues",
273
+ description="Get all issues in a sprint.",
274
+ inputSchema={
275
+ "type": "object",
276
+ "properties": {
277
+ "sprint_id": {
278
+ "type": "integer",
279
+ "description": "Sprint ID"
280
+ },
281
+ "fields": {
282
+ "type": "array",
283
+ "items": {"type": "string"},
284
+ "description": "Optional list of specific fields to retrieve"
285
+ },
286
+ "max_results": {
287
+ "type": "integer",
288
+ "description": "Maximum number of results (default: 200)",
289
+ "default": 200
290
+ }
291
+ },
292
+ "required": ["sprint_id"]
293
+ }
294
+ ),
295
+ ]