Coverage for src/mcp_atlassian/server.py: 0%
267 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-08 18:10 +0900
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-08 18:10 +0900
1import json
2import logging
3import os
4from collections.abc import Sequence
5from typing import Any
7from mcp.server import Server
8from mcp.types import Resource, TextContent, Tool
9from pydantic import AnyUrl
11from .confluence import ConfluenceFetcher
12from .jira import JiraFetcher
13from .preprocessing import markdown_to_confluence_storage
15# Configure logging
16logging.basicConfig(
17 level=logging.INFO,
18 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19 filename="mcp_atlassian_debug.log",
20 filemode="a",
21)
22logger = logging.getLogger("mcp-atlassian")
23logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.INFO)
26def get_available_services():
27 """Determine which services are available based on environment variables."""
28 confluence_vars = all(
29 [
30 os.getenv("CONFLUENCE_URL"),
31 os.getenv("CONFLUENCE_USERNAME"),
32 os.getenv("CONFLUENCE_API_TOKEN"),
33 ]
34 )
36 jira_vars = all([os.getenv("JIRA_URL"), os.getenv("JIRA_USERNAME"), os.getenv("JIRA_API_TOKEN")])
38 return {"confluence": confluence_vars, "jira": jira_vars}
41# Initialize services based on available credentials
42services = get_available_services()
43confluence_fetcher = ConfluenceFetcher() if services["confluence"] else None
44jira_fetcher = JiraFetcher() if services["jira"] else None
45app = Server("mcp-atlassian")
48@app.list_resources()
49async def list_resources() -> list[Resource]:
50 """List Confluence spaces and Jira projects the user is actively interacting with."""
51 resources = []
53 # Add Confluence spaces the user has contributed to
54 if confluence_fetcher:
55 try:
56 # Get spaces the user has contributed to
57 spaces = confluence_fetcher.get_user_contributed_spaces(limit=250)
59 # Add spaces to resources
60 resources.extend(
61 [
62 Resource(
63 uri=AnyUrl(f"confluence://{space['key']}"),
64 name=f"Confluence Space: {space['name']}",
65 mimeType="text/plain",
66 description=(
67 f"A Confluence space containing documentation and knowledge base articles. "
68 f"Space Key: {space['key']}. "
69 f"{space.get('description', '')} "
70 f"Access content using: confluence://{space['key']}/pages/PAGE_TITLE"
71 ).strip(),
72 )
73 for space in spaces.values()
74 ]
75 )
76 except Exception as e:
77 logger.error(f"Error fetching Confluence spaces: {str(e)}")
79 # Add Jira projects the user is involved with
80 if jira_fetcher:
81 try:
82 # Get current user's account ID
83 account_id = jira_fetcher.get_current_user_account_id()
85 # Use JQL to find issues the user is assigned to or reported
86 jql = f"assignee = {account_id} OR reporter = {account_id} ORDER BY updated DESC"
87 issues = jira_fetcher.jira.jql(jql, limit=250, fields=["project"])
89 # Extract and deduplicate projects
90 projects = {}
91 for issue in issues.get("issues", []):
92 project = issue.get("fields", {}).get("project", {})
93 project_key = project.get("key")
94 if project_key and project_key not in projects:
95 projects[project_key] = {
96 "key": project_key,
97 "name": project.get("name", project_key),
98 "description": project.get("description", ""),
99 }
101 # Add projects to resources
102 resources.extend(
103 [
104 Resource(
105 uri=AnyUrl(f"jira://{project['key']}"),
106 name=f"Jira Project: {project['name']}",
107 mimeType="text/plain",
108 description=(
109 f"A Jira project tracking issues and tasks. Project Key: {project['key']}. "
110 ).strip(),
111 )
112 for project in projects.values()
113 ]
114 )
115 except Exception as e:
116 logger.error(f"Error fetching Jira projects: {str(e)}")
118 return resources
121@app.read_resource()
122async def read_resource(uri: AnyUrl) -> str:
123 """Read content from Confluence or Jira."""
124 uri_str = str(uri)
126 # Handle Confluence resources
127 if uri_str.startswith("confluence://"):
128 if not services["confluence"]:
129 raise ValueError("Confluence is not configured. Please provide Confluence credentials.")
130 parts = uri_str.replace("confluence://", "").split("/")
132 # Handle space listing
133 if len(parts) == 1:
134 space_key = parts[0]
136 # Use CQL to find recently updated pages in this space
137 cql = f'space = "{space_key}" AND contributor = currentUser() ORDER BY lastmodified DESC'
138 documents = confluence_fetcher.search(cql=cql, limit=20)
140 if not documents:
141 # Fallback to regular space pages if no user-contributed pages found
142 documents = confluence_fetcher.get_space_pages(space_key, limit=10)
144 content = []
145 for doc in documents:
146 title = doc.metadata.get("title", "Untitled")
147 url = doc.metadata.get("url", "")
149 content.append(f"# [{title}]({url})\n\n{doc.page_content}\n\n---")
151 return "\n\n".join(content)
153 # Handle specific page
154 elif len(parts) >= 3 and parts[1] == "pages":
155 space_key = parts[0]
156 title = parts[2]
157 doc = confluence_fetcher.get_page_by_title(space_key, title)
159 if not doc:
160 raise ValueError(f"Page not found: {title}")
162 return doc.page_content
164 # Handle Jira resources
165 elif uri_str.startswith("jira://"):
166 if not services["jira"]:
167 raise ValueError("Jira is not configured. Please provide Jira credentials.")
168 parts = uri_str.replace("jira://", "").split("/")
170 # Handle project listing
171 if len(parts) == 1:
172 project_key = parts[0]
174 # Get current user's account ID
175 account_id = jira_fetcher.get_current_user_account_id()
177 # Use JQL to find issues in this project that the user is involved with
178 jql = f"project = {project_key} AND (assignee = {account_id} OR reporter = {account_id}) ORDER BY updated DESC"
179 issues = jira_fetcher.search_issues(jql=jql, limit=20)
181 if not issues:
182 # Fallback to recent issues if no user-related issues found
183 issues = jira_fetcher.get_project_issues(project_key, limit=10)
185 content = []
186 for issue in issues:
187 key = issue.metadata.get("key", "")
188 title = issue.metadata.get("title", "Untitled")
189 url = issue.metadata.get("url", "")
190 status = issue.metadata.get("status", "")
192 content.append(f"# [{key}: {title}]({url})\nStatus: {status}\n\n{issue.page_content}\n\n---")
194 return "\n\n".join(content)
196 # Handle specific issue
197 elif len(parts) >= 3 and parts[1] == "issues":
198 issue_key = parts[2]
199 issue = jira_fetcher.get_issue(issue_key)
200 return issue.page_content
202 raise ValueError(f"Invalid resource URI: {uri}")
205@app.list_tools()
206async def list_tools() -> list[Tool]:
207 """List available Confluence and Jira tools."""
208 tools = []
210 if confluence_fetcher:
211 tools.extend(
212 [
213 Tool(
214 name="confluence_search",
215 description="Search Confluence content using CQL",
216 inputSchema={
217 "type": "object",
218 "properties": {
219 "query": {
220 "type": "string",
221 "description": "CQL query string (e.g. 'type=page AND space=DEV')",
222 },
223 "limit": {
224 "type": "number",
225 "description": "Maximum number of results (1-50)",
226 "default": 10,
227 "minimum": 1,
228 "maximum": 50,
229 },
230 },
231 "required": ["query"],
232 },
233 ),
234 Tool(
235 name="confluence_get_page",
236 description="Get content of a specific Confluence page by ID",
237 inputSchema={
238 "type": "object",
239 "properties": {
240 "page_id": {
241 "type": "string",
242 "description": "Confluence page ID (numeric ID, can be parsed from URL, e.g. from 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title' -> '123456789')",
243 },
244 "include_metadata": {
245 "type": "boolean",
246 "description": "Whether to include page metadata",
247 "default": True,
248 },
249 },
250 "required": ["page_id"],
251 },
252 ),
253 Tool(
254 name="confluence_get_comments",
255 description="Get comments for a specific Confluence page",
256 inputSchema={
257 "type": "object",
258 "properties": {
259 "page_id": {
260 "type": "string",
261 "description": "Confluence page ID (numeric ID, can be parsed from URL, e.g. from 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title' -> '123456789')",
262 }
263 },
264 "required": ["page_id"],
265 },
266 ),
267 Tool(
268 name="confluence_create_page",
269 description="Create a new Confluence page",
270 inputSchema={
271 "type": "object",
272 "properties": {
273 "space_key": {
274 "type": "string",
275 "description": "The key of the space to create the page in",
276 },
277 "title": {
278 "type": "string",
279 "description": "The title of the page",
280 },
281 "content": {
282 "type": "string",
283 "description": "The content of the page in Markdown format",
284 },
285 "parent_id": {
286 "type": "string",
287 "description": "Optional parent page ID",
288 },
289 },
290 "required": ["space_key", "title", "content"],
291 },
292 ),
293 Tool(
294 name="confluence_update_page",
295 description="Update an existing Confluence page",
296 inputSchema={
297 "type": "object",
298 "properties": {
299 "page_id": {
300 "type": "string",
301 "description": "The ID of the page to update",
302 },
303 "title": {
304 "type": "string",
305 "description": "The new title of the page",
306 },
307 "content": {
308 "type": "string",
309 "description": "The new content of the page in Markdown format",
310 },
311 "minor_edit": {
312 "type": "boolean",
313 "description": "Whether this is a minor edit",
314 "default": False,
315 },
316 "version_comment": {
317 "type": "string",
318 "description": "Optional comment for this version",
319 "default": "",
320 },
321 },
322 "required": ["page_id", "title", "content"],
323 },
324 ),
325 ]
326 )
328 if jira_fetcher:
329 tools.extend(
330 [
331 Tool(
332 name="jira_get_issue",
333 description="Get details of a specific Jira issue including its Epic links and relationship information",
334 inputSchema={
335 "type": "object",
336 "properties": {
337 "issue_key": {
338 "type": "string",
339 "description": "Jira issue key (e.g., 'PROJ-123')",
340 },
341 "expand": {
342 "type": "string",
343 "description": "Optional fields to expand. Examples: 'renderedFields' (for rendered content), 'transitions' (for available status transitions), 'changelog' (for history)",
344 "default": None,
345 },
346 "comment_limit": {
347 "type": "integer",
348 "description": "Maximum number of comments to include (0 or null for no comments)",
349 "minimum": 0,
350 "maximum": 100,
351 "default": None,
352 },
353 },
354 "required": ["issue_key"],
355 },
356 ),
357 Tool(
358 name="jira_search",
359 description="Search Jira issues using JQL (Jira Query Language)",
360 inputSchema={
361 "type": "object",
362 "properties": {
363 "jql": {
364 "type": "string",
365 "description": "JQL query string. Examples:\n"
366 '- Find Epics: "issuetype = Epic AND project = PROJ"\n'
367 '- Find issues in Epic: "parent = PROJ-123"\n'
368 "- Find by status: \"status = 'In Progress' AND project = PROJ\"\n"
369 '- Find by assignee: "assignee = currentUser()"\n'
370 '- Find recently updated: "updated >= -7d AND project = PROJ"\n'
371 '- Find by label: "labels = frontend AND project = PROJ"',
372 },
373 "fields": {
374 "type": "string",
375 "description": "Comma-separated fields to return",
376 "default": "*all",
377 },
378 "limit": {
379 "type": "number",
380 "description": "Maximum number of results (1-50)",
381 "default": 10,
382 "minimum": 1,
383 "maximum": 50,
384 },
385 },
386 "required": ["jql"],
387 },
388 ),
389 Tool(
390 name="jira_get_project_issues",
391 description="Get all issues for a specific Jira project",
392 inputSchema={
393 "type": "object",
394 "properties": {
395 "project_key": {
396 "type": "string",
397 "description": "The project key",
398 },
399 "limit": {
400 "type": "number",
401 "description": "Maximum number of results (1-50)",
402 "default": 10,
403 "minimum": 1,
404 "maximum": 50,
405 },
406 },
407 "required": ["project_key"],
408 },
409 ),
410 Tool(
411 name="jira_create_issue",
412 description="Create a new Jira issue with optional Epic link",
413 inputSchema={
414 "type": "object",
415 "properties": {
416 "project_key": {
417 "type": "string",
418 "description": "The JIRA project key (e.g. 'PROJ'). Never assume what it might be, always ask the user.",
419 },
420 "summary": {
421 "type": "string",
422 "description": "Summary/title of the issue",
423 },
424 "issue_type": {
425 "type": "string",
426 "description": "Issue type (e.g. 'Task', 'Bug', 'Story')",
427 },
428 "assignee": {
429 "type": "string",
430 "description": "Assignee of the ticket (accountID, full name or e-mail)",
431 },
432 "description": {
433 "type": "string",
434 "description": "Issue description",
435 "default": "",
436 },
437 "additional_fields": {
438 "type": "string",
439 "description": "Optional JSON string of additional fields to set. Examples:\n"
440 '- Link to Epic: {"parent": {"key": "PROJ-123"}} - For linking to an Epic after creation, prefer using the jira_link_to_epic tool instead\n'
441 '- Set priority: {"priority": {"name": "High"}} or {"priority": null} for no priority (common values: High, Medium, Low, None)\n'
442 '- Add labels: {"labels": ["label1", "label2"]}\n'
443 '- Set due date: {"duedate": "2023-12-31"}\n'
444 '- Custom fields: {"customfield_10XXX": "value"}',
445 "default": "{}",
446 },
447 },
448 "required": ["project_key", "summary", "issue_type"],
449 },
450 ),
451 Tool(
452 name="jira_update_issue",
453 description="Update an existing Jira issue including changing status, adding Epic links, updating fields, etc.",
454 inputSchema={
455 "type": "object",
456 "properties": {
457 "issue_key": {
458 "type": "string",
459 "description": "Jira issue key (e.g., 'PROJ-123')",
460 },
461 "fields": {
462 "type": "string",
463 "description": "A valid JSON object of fields to update. Examples:\n"
464 '- Add to Epic: {"parent": {"key": "PROJ-456"}} - Prefer using the dedicated jira_link_to_epic tool instead\n'
465 '- Change assignee: {"assignee": "user@email.com"} or {"assignee": null} to unassign\n'
466 '- Update summary: {"summary": "New title"}\n'
467 '- Update description: {"description": "New description"}\n'
468 "- Change status: requires transition IDs - use jira_get_transitions and jira_transition_issue instead\n"
469 '- Add labels: {"labels": ["label1", "label2"]}\n'
470 '- Set priority: {"priority": {"name": "High"}} or {"priority": null} for no priority (common values: High, Medium, Low, None)\n'
471 '- Update custom fields: {"customfield_10XXX": "value"}',
472 },
473 "additional_fields": {
474 "type": "string",
475 "description": "Optional JSON string of additional fields to update",
476 "default": "{}",
477 },
478 },
479 "required": ["issue_key", "fields"],
480 },
481 ),
482 Tool(
483 name="jira_delete_issue",
484 description="Delete an existing Jira issue",
485 inputSchema={
486 "type": "object",
487 "properties": {
488 "issue_key": {
489 "type": "string",
490 "description": "Jira issue key (e.g. PROJ-123)",
491 },
492 },
493 "required": ["issue_key"],
494 },
495 ),
496 Tool(
497 name="jira_add_comment",
498 description="Add a comment to a Jira issue",
499 inputSchema={
500 "type": "object",
501 "properties": {
502 "issue_key": {
503 "type": "string",
504 "description": "Jira issue key (e.g., 'PROJ-123')",
505 },
506 "comment": {
507 "type": "string",
508 "description": "Comment text in Markdown format",
509 },
510 },
511 "required": ["issue_key", "comment"],
512 },
513 ),
514 Tool(
515 name="jira_link_to_epic",
516 description="Link an existing issue to an epic",
517 inputSchema={
518 "type": "object",
519 "properties": {
520 "issue_key": {
521 "type": "string",
522 "description": "The key of the issue to link (e.g., 'PROJ-123')",
523 },
524 "epic_key": {
525 "type": "string",
526 "description": "The key of the epic to link to (e.g., 'PROJ-456')",
527 },
528 },
529 "required": ["issue_key", "epic_key"],
530 },
531 ),
532 Tool(
533 name="jira_get_epic_issues",
534 description="Get all issues linked to a specific epic",
535 inputSchema={
536 "type": "object",
537 "properties": {
538 "epic_key": {
539 "type": "string",
540 "description": "The key of the epic (e.g., 'PROJ-123')",
541 },
542 "limit": {
543 "type": "number",
544 "description": "Maximum number of issues to return (1-50)",
545 "default": 10,
546 "minimum": 1,
547 "maximum": 50,
548 },
549 },
550 "required": ["epic_key"],
551 },
552 ),
553 Tool(
554 name="jira_get_transitions",
555 description="Get available status transitions for a Jira issue",
556 inputSchema={
557 "type": "object",
558 "properties": {
559 "issue_key": {
560 "type": "string",
561 "description": "Jira issue key (e.g., 'PROJ-123')",
562 },
563 },
564 "required": ["issue_key"],
565 },
566 ),
567 Tool(
568 name="jira_transition_issue",
569 description="Transition a Jira issue to a new status",
570 inputSchema={
571 "type": "object",
572 "properties": {
573 "issue_key": {
574 "type": "string",
575 "description": "Jira issue key (e.g., 'PROJ-123')",
576 },
577 "transition_id": {
578 "type": "string",
579 "description": "ID of the transition to perform (get this from jira_get_transitions)",
580 },
581 "fields": {
582 "type": "string",
583 "description": "JSON string of fields to update during the transition (optional)",
584 "default": "{}",
585 },
586 "comment": {
587 "type": "string",
588 "description": "Comment to add during the transition (optional)",
589 },
590 },
591 "required": ["issue_key", "transition_id"],
592 },
593 ),
594 ]
595 )
597 return tools
600@app.call_tool()
601async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
602 """Handle tool calls for Confluence and Jira operations."""
603 try:
604 # Helper functions for formatting results
605 def format_comment(comment):
606 return {
607 "id": comment.get("id"),
608 "author": comment.get("author", {}).get("displayName", "Unknown"),
609 "created": comment.get("created"),
610 "body": comment.get("body"),
611 }
613 def format_issue(doc):
614 return {
615 "key": doc.metadata["key"],
616 "title": doc.metadata["title"],
617 "type": doc.metadata["type"],
618 "status": doc.metadata["status"],
619 "created_date": doc.metadata["created_date"],
620 "priority": doc.metadata["priority"],
621 "link": doc.metadata["link"],
622 }
624 def format_transition(transition):
625 return {
626 "id": transition.get("id"),
627 "name": transition.get("name"),
628 "to_status": transition.get("to", {}).get("name"),
629 }
631 # Confluence operations
632 if name == "confluence_search":
633 limit = min(int(arguments.get("limit", 10)), 50)
634 documents = confluence_fetcher.search(arguments["query"], limit)
635 search_results = [
636 {
637 "page_id": doc.metadata["page_id"],
638 "title": doc.metadata["title"],
639 "space": doc.metadata["space"],
640 "url": doc.metadata["url"],
641 "last_modified": doc.metadata["last_modified"],
642 "type": doc.metadata["type"],
643 "excerpt": doc.page_content,
644 }
645 for doc in documents
646 ]
648 return [TextContent(type="text", text=json.dumps(search_results, indent=2, ensure_ascii=False))]
650 elif name == "confluence_get_page":
651 doc = confluence_fetcher.get_page_content(arguments["page_id"])
652 include_metadata = arguments.get("include_metadata", True)
654 if include_metadata:
655 result = {"content": doc.page_content, "metadata": doc.metadata}
656 else:
657 result = {"content": doc.page_content}
659 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
661 elif name == "confluence_get_comments":
662 comments = confluence_fetcher.get_page_comments(arguments["page_id"])
663 formatted_comments = [format_comment(comment) for comment in comments]
665 return [TextContent(type="text", text=json.dumps(formatted_comments, indent=2, ensure_ascii=False))]
667 elif name == "confluence_create_page":
668 # Convert markdown content to HTML storage format
669 space_key = arguments["space_key"]
670 title = arguments["title"]
671 content = arguments["content"]
672 parent_id = arguments.get("parent_id")
674 # Convert markdown to Confluence storage format
675 storage_format = markdown_to_confluence_storage(content)
677 # Create the page
678 doc = confluence_fetcher.create_page(
679 space_key=space_key,
680 title=title,
681 body=storage_format, # Now using the converted storage format
682 parent_id=parent_id,
683 )
685 result = {
686 "page_id": doc.metadata["page_id"],
687 "title": doc.metadata["title"],
688 "space_key": doc.metadata["space_key"],
689 "url": doc.metadata["url"],
690 "version": doc.metadata["version"],
691 "content": doc.page_content[:500] + "..." if len(doc.page_content) > 500 else doc.page_content,
692 }
694 return [
695 TextContent(
696 type="text", text=f"Page created successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}"
697 )
698 ]
700 elif name == "confluence_update_page":
701 page_id = arguments["page_id"]
702 title = arguments["title"]
703 content = arguments["content"]
704 minor_edit = arguments.get("minor_edit", False)
706 # Convert markdown to Confluence storage format
707 storage_format = markdown_to_confluence_storage(content)
709 # Update the page
710 doc = confluence_fetcher.update_page(
711 page_id=page_id,
712 title=title,
713 body=storage_format, # Now using the converted storage format
714 minor_edit=minor_edit,
715 version_comment=arguments.get("version_comment", ""),
716 )
718 result = {
719 "page_id": doc.metadata["page_id"],
720 "title": doc.metadata["title"],
721 "space_key": doc.metadata["space_key"],
722 "url": doc.metadata["url"],
723 "version": doc.metadata["version"],
724 "content": doc.page_content[:500] + "..." if len(doc.page_content) > 500 else doc.page_content,
725 }
727 return [
728 TextContent(
729 type="text", text=f"Page updated successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}"
730 )
731 ]
733 # Jira operations
734 elif name == "jira_get_issue":
735 doc = jira_fetcher.get_issue(
736 arguments["issue_key"], expand=arguments.get("expand"), comment_limit=arguments.get("comment_limit")
737 )
738 result = {"content": doc.page_content, "metadata": doc.metadata}
739 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
741 elif name == "jira_search":
742 limit = min(int(arguments.get("limit", 10)), 50)
743 documents = jira_fetcher.search_issues(
744 arguments["jql"], fields=arguments.get("fields", "*all"), limit=limit
745 )
746 search_results = [format_issue(doc) for doc in documents]
747 return [TextContent(type="text", text=json.dumps(search_results, indent=2, ensure_ascii=False))]
749 elif name == "jira_get_project_issues":
750 limit = min(int(arguments.get("limit", 10)), 50)
751 documents = jira_fetcher.get_project_issues(arguments["project_key"], limit=limit)
752 project_issues = [format_issue(doc) for doc in documents]
753 return [TextContent(type="text", text=json.dumps(project_issues, indent=2, ensure_ascii=False))]
755 elif name == "jira_create_issue":
756 additional_fields = json.loads(arguments.get("additional_fields", "{}"))
758 # If assignee is in additional_fields, move it to the main arguments
759 if "assignee" in additional_fields:
760 if not arguments.get("assignee"): # Only if not already specified in main arguments
761 assignee_data = additional_fields.pop("assignee")
762 if isinstance(assignee_data, dict):
763 arguments["assignee"] = assignee_data.get("id") or assignee_data.get("accountId")
764 else:
765 arguments["assignee"] = str(assignee_data)
767 doc = jira_fetcher.create_issue(
768 project_key=arguments["project_key"],
769 summary=arguments["summary"],
770 issue_type=arguments["issue_type"],
771 description=arguments.get("description", ""),
772 assignee=arguments.get("assignee"),
773 **additional_fields,
774 )
775 result = json.dumps({"content": doc.page_content, "metadata": doc.metadata}, indent=2, ensure_ascii=False)
776 return [TextContent(type="text", text=f"Issue created successfully:\n{result}")]
778 elif name == "jira_update_issue":
779 fields = json.loads(arguments["fields"])
780 additional_fields = json.loads(arguments.get("additional_fields", "{}"))
782 doc = jira_fetcher.update_issue(issue_key=arguments["issue_key"], fields=fields, **additional_fields)
783 result = json.dumps({"content": doc.page_content, "metadata": doc.metadata}, indent=2, ensure_ascii=False)
784 return [TextContent(type="text", text=f"Issue updated successfully:\n{result}")]
786 elif name == "jira_delete_issue":
787 issue_key = arguments["issue_key"]
788 deleted = jira_fetcher.delete_issue(issue_key)
789 result = {"message": f"Issue {issue_key} has been deleted successfully."}
790 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
792 elif name == "jira_add_comment":
793 comment = jira_fetcher.add_comment(arguments["issue_key"], arguments["comment"])
794 result = {"message": "Comment added successfully", "comment": comment}
795 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
797 elif name == "jira_link_to_epic":
798 issue_key = arguments["issue_key"]
799 epic_key = arguments["epic_key"]
800 linked_issue = jira_fetcher.link_issue_to_epic(issue_key, epic_key)
801 result = {
802 "message": f"Issue {issue_key} has been linked to epic {epic_key}.",
803 "issue": {
804 "key": linked_issue.metadata["key"],
805 "title": linked_issue.metadata["title"],
806 "type": linked_issue.metadata["type"],
807 "status": linked_issue.metadata["status"],
808 "link": linked_issue.metadata["link"],
809 },
810 }
811 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
813 elif name == "jira_get_epic_issues":
814 epic_key = arguments["epic_key"]
815 limit = min(int(arguments.get("limit", 10)), 50)
816 documents = jira_fetcher.get_epic_issues(epic_key, limit=limit)
817 epic_issues = [format_issue(doc) for doc in documents]
818 return [TextContent(type="text", text=json.dumps(epic_issues, indent=2, ensure_ascii=False))]
820 elif name == "jira_get_transitions":
821 issue_key = arguments["issue_key"]
822 transitions = jira_fetcher.get_available_transitions(issue_key)
823 transitions_result = [format_transition(transition) for transition in transitions]
824 return [TextContent(type="text", text=json.dumps(transitions_result, indent=2, ensure_ascii=False))]
826 elif name == "jira_transition_issue":
827 import base64
829 import httpx
831 issue_key = arguments["issue_key"]
832 transition_id = arguments["transition_id"]
834 # Convert transition_id to string if it's not already
835 if not isinstance(transition_id, str):
836 transition_id = str(transition_id)
838 # Get Jira API credentials from environment/config
839 jira_url = jira_fetcher.config.url.rstrip("/")
840 username = jira_fetcher.config.username
841 api_token = jira_fetcher.config.api_token
843 # Construct minimal transition payload
844 payload = {"transition": {"id": transition_id}}
846 # Add fields if provided
847 if "fields" in arguments:
848 try:
849 fields = json.loads(arguments.get("fields", "{}"))
850 if fields and isinstance(fields, dict):
851 payload["fields"] = fields
852 except Exception as e:
853 return [
854 TextContent(
855 type="text",
856 text=json.dumps(
857 {"error": f"Invalid fields format: {str(e)}", "status": "error"},
858 indent=2,
859 ensure_ascii=False,
860 ),
861 )
862 ]
864 # Add comment if provided
865 if "comment" in arguments and arguments["comment"]:
866 comment = arguments["comment"]
867 if not isinstance(comment, str):
868 comment = str(comment)
870 payload["update"] = {"comment": [{"add": {"body": comment}}]}
872 # Create auth header
873 auth_str = f"{username}:{api_token}"
874 auth_bytes = auth_str.encode("ascii")
875 auth_b64 = base64.b64encode(auth_bytes).decode("ascii")
877 # Prepare headers
878 headers = {
879 "Authorization": f"Basic {auth_b64}",
880 "Content-Type": "application/json",
881 "Accept": "application/json",
882 }
884 # Log entire request for debugging
885 logger.info(f"Sending transition request to {jira_url}/rest/api/2/issue/{issue_key}/transitions")
886 logger.info(f"Headers: {headers}")
887 logger.info(f"Payload: {payload}")
889 try:
890 # Make direct HTTP request
891 transition_url = f"{jira_url}/rest/api/2/issue/{issue_key}/transitions"
892 response = httpx.post(transition_url, json=payload, headers=headers, timeout=30.0)
894 # Check response
895 if response.status_code >= 400:
896 return [
897 TextContent(
898 type="text",
899 text=json.dumps(
900 {
901 "error": f"Jira API error: {response.status_code} - {response.text}",
902 "status": "error",
903 },
904 indent=2,
905 ensure_ascii=False,
906 ),
907 )
908 ]
910 # Now fetch the updated issue - also using direct HTTP
911 issue_url = f"{jira_url}/rest/api/2/issue/{issue_key}"
912 issue_response = httpx.get(issue_url, headers=headers, timeout=30.0)
914 if issue_response.status_code >= 400:
915 return [
916 TextContent(
917 type="text",
918 text=json.dumps(
919 {
920 "error": f"Failed to fetch updated issue: {issue_response.status_code} - {issue_response.text}",
921 "status": "error",
922 },
923 indent=2,
924 ensure_ascii=False,
925 ),
926 )
927 ]
929 # Parse and return issue data
930 issue_data = issue_response.json()
932 # Extract essential issue information
933 status = issue_data["fields"]["status"]["name"]
934 summary = issue_data["fields"].get("summary", "")
935 issue_type = issue_data["fields"]["issuetype"]["name"]
937 # Clean and process description text if available
938 description = ""
939 if issue_data["fields"].get("description"):
940 description = jira_fetcher.preprocessor.clean_jira_text(issue_data["fields"]["description"])
942 result = {
943 "message": f"Successfully transitioned issue {issue_key} to {status}",
944 "issue": {
945 "key": issue_key,
946 "title": summary,
947 "type": issue_type,
948 "status": status,
949 "description": description,
950 },
951 }
953 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
955 except Exception as e:
956 error_message = str(e)
957 logger.error(f"Exception in direct transition API call: {error_message}")
958 return [
959 TextContent(
960 type="text",
961 text=json.dumps(
962 {
963 "error": f"Network or API error: {error_message}",
964 "status": "error",
965 "details": f"Full error: {repr(e)}",
966 },
967 indent=2,
968 ensure_ascii=False,
969 ),
970 )
971 ]
973 raise ValueError(f"Unknown tool: {name}")
975 except Exception as e:
976 logger.error(f"Tool execution error: {str(e)}")
977 raise RuntimeError(f"Tool execution failed: {str(e)}")
980async def main():
981 # Import here to avoid issues with event loops
982 from mcp.server.stdio import stdio_server
984 async with stdio_server() as (read_stream, write_stream):
985 await app.run(read_stream, write_stream, app.create_initialization_options())
988if __name__ == "__main__":
989 import asyncio
991 asyncio.run(main())