Coverage for src/mcp_atlassian/server.py: 0%

1 import json

2 import logging

3 import os

4 from collections.abc import Sequence

5 from typing import Any

6

7 from mcp.server import Server

8 from mcp.types import Resource, TextContent, Tool

9 from pydantic import AnyUrl

10

11 from .confluence import ConfluenceFetcher

12 from .jira import JiraFetcher

13 from .preprocessing import markdown_to_confluence_storage

14

15# Configure logging

16 logging.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)

22 logger = logging.getLogger("mcp-atlassian")

23 logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.INFO)

24

25

26 def 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 )

35

36 jira_vars = all([os.getenv("JIRA_URL"), os.getenv("JIRA_USERNAME"), os.getenv("JIRA_API_TOKEN")])

37

38 return {"confluence": confluence_vars, "jira": jira_vars}

39

40

41# Initialize services based on available credentials

42 services = get_available_services()

43 confluence_fetcher = ConfluenceFetcher() if services["confluence"] else None

44 jira_fetcher = JiraFetcher() if services["jira"] else None

45 app = Server("mcp-atlassian")

46

47

48@app.list_resources()

49 async def list_resources() -> list[Resource]:

50 """List Confluence spaces and Jira projects the user is actively interacting with."""

51 resources = []

52

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)

58

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)}")

78

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()

84

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"])

88

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 }

100

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)}")

117

118 return resources

119

120

121@app.read_resource()

122 async def read_resource(uri: AnyUrl) -> str:

123 """Read content from Confluence or Jira."""

124 uri_str = str(uri)

125

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("/")

131

132 # Handle space listing

133 if len(parts) == 1:

134 space_key = parts[0]

135

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)

139

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)

143

144 content = []

145 for doc in documents:

146 title = doc.metadata.get("title", "Untitled")

147 url = doc.metadata.get("url", "")

148

149 content.append(f"# [{title}]({url})\n\n{doc.page_content}\n\n---")

150

151 return "\n\n".join(content)

152

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)

158

159 if not doc:

160 raise ValueError(f"Page not found: {title}")

161

162 return doc.page_content

163

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("/")

169

170 # Handle project listing

171 if len(parts) == 1:

172 project_key = parts[0]

173

174 # Get current user's account ID

175 account_id = jira_fetcher.get_current_user_account_id()

176

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)

180

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)

184

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", "")

191

192 content.append(f"# [{key}: {title}]({url})\nStatus: {status}\n\n{issue.page_content}\n\n---")

193

194 return "\n\n".join(content)

195

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

201

202 raise ValueError(f"Invalid resource URI: {uri}")

203

204

205@app.list_tools()

206 async def list_tools() -> list[Tool]:

207 """List available Confluence and Jira tools."""

208 tools = []

209

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 )

327

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 )

596

597 return tools

598

599

600@app.call_tool()

601 async 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 }

612

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 }

623

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 }

630

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 ]

647

648 return [TextContent(type="text", text=json.dumps(search_results, indent=2, ensure_ascii=False))]

649

650 elif name == "confluence_get_page":

651 doc = confluence_fetcher.get_page_content(arguments["page_id"])

652 include_metadata = arguments.get("include_metadata", True)

653

654 if include_metadata:

655 result = {"content": doc.page_content, "metadata": doc.metadata}

656 else:

657 result = {"content": doc.page_content}

658

659 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]

660

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]

664

665 return [TextContent(type="text", text=json.dumps(formatted_comments, indent=2, ensure_ascii=False))]

666

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")

673

674 # Convert markdown to Confluence storage format

675 storage_format = markdown_to_confluence_storage(content)

676

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 )

684

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 }

693

694 return [

695 TextContent(

696 type="text", text=f"Page created successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}"

697 )

698 ]

699

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)

705

706 # Convert markdown to Confluence storage format

707 storage_format = markdown_to_confluence_storage(content)

708

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 )

717

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 }

726

727 return [

728 TextContent(

729 type="text", text=f"Page updated successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}"

730 )

731 ]

732

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))]

740

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))]

748

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))]

754

755 elif name == "jira_create_issue":

756 additional_fields = json.loads(arguments.get("additional_fields", "{}"))

757

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)

766

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}")]

777

778 elif name == "jira_update_issue":

779 fields = json.loads(arguments["fields"])

780 additional_fields = json.loads(arguments.get("additional_fields", "{}"))

781

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}")]

785

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))]

791

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))]

796

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))]

812

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))]

819

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))]

825

826 elif name == "jira_transition_issue":

827 import base64

828

829 import httpx

830

831 issue_key = arguments["issue_key"]

832 transition_id = arguments["transition_id"]

833

834 # Convert transition_id to string if it's not already

835 if not isinstance(transition_id, str):

836 transition_id = str(transition_id)

837

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

842

843 # Construct minimal transition payload

844 payload = {"transition": {"id": transition_id}}

845

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 ]

863

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)

869

870 payload["update"] = {"comment": [{"add": {"body": comment}}]}

871

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")

876

877 # Prepare headers

878 headers = {

879 "Authorization": f"Basic {auth_b64}",

880 "Content-Type": "application/json",

881 "Accept": "application/json",

882 }

883

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}")

888

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)

893

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 ]

909

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)

913

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 ]

928

929 # Parse and return issue data

930 issue_data = issue_response.json()

931

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"]

936

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"])

941

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 }

952

953 return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]

954

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 ]

972

973 raise ValueError(f"Unknown tool: {name}")

974

975 except Exception as e:

976 logger.error(f"Tool execution error: {str(e)}")

977 raise RuntimeError(f"Tool execution failed: {str(e)}")

978

979

980 async def main():

981 # Import here to avoid issues with event loops

982 from mcp.server.stdio import stdio_server

983

984 async with stdio_server() as (read_stream, write_stream):

985 await app.run(read_stream, write_stream, app.create_initialization_options())

986

987

988 if __name__ == "__main__":

989 import asyncio

990

991 asyncio.run(main())