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

1 import json

2 import logging

3 import os

4 from collections.abc import Callable, 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 .document_types import Document

13 from .jira import JiraFetcher

14 from .preprocessing import markdown_to_confluence_storage

15

16# Configure logging

17 logging.basicConfig(

18 level=logging.INFO,

19 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",

20 filename="mcp_atlassian_debug.log",

21 filemode="a",

22)

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

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

25

26# Type aliases for formatter functions

27 CommentFormatter = Callable[[dict[str, Any]], dict[str, Any]]

28 IssueFormatter = Callable[[Document], dict[str, Any]]

29 TransitionFormatter = Callable[[dict[str, Any]], dict[str, Any]]

30

31

32 def get_available_services() -> dict[str, bool | None]:

33 """Determine which services are available based on environment variables."""

34 confluence_vars = all(

35 [

36 os.getenv("CONFLUENCE_URL"),

37 os.getenv("CONFLUENCE_USERNAME"),

38 os.getenv("CONFLUENCE_API_TOKEN"),

39 ]

40 )

41

42 # Check for either cloud authentication (URL + username + API token)

43 # or server/data center authentication (URL + personal token)

44 jira_url = os.getenv("JIRA_URL")

45 if jira_url:

46 is_cloud = "atlassian.net" in jira_url

47 if is_cloud:

48 jira_vars = all(

49 [jira_url, os.getenv("JIRA_USERNAME"), os.getenv("JIRA_API_TOKEN")]

50 )

51 logger.info("Using Jira Cloud authentication method")

52 else:

53 jira_vars = all([jira_url, os.getenv("JIRA_PERSONAL_TOKEN")])

54 logger.info("Using Jira Server/Data Center authentication method")

55 else:

56 jira_vars = False

57

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

59

60

61# Initialize services based on available credentials

62 services = get_available_services()

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

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

65 app = Server("mcp-atlassian")

66

67

68@app.list_resources()

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

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

71 resources = []

72

73 # Add Confluence spaces the user has contributed to

74 if confluence_fetcher:

75 try:

76 # Get spaces the user has contributed to

77 spaces = confluence_fetcher.get_user_contributed_spaces(limit=250)

78

79 # Add spaces to resources

80 resources.extend(

81 [

82 Resource(

83 uri=AnyUrl(f"confluence://{space['key']}"),

84 name=f"Confluence Space: {space['name']}",

85 mimeType="text/plain",

86 description=(

87 f"A Confluence space containing documentation and knowledge base articles. "

88 f"Space Key: {space['key']}. "

89 f"{space.get('description', '')} "

90 f"Access content using: confluence://{space['key']}/pages/PAGE_TITLE"

91 ).strip(),

92 )

93 for space in spaces.values()

94 ]

95 )

96 except KeyError as e:

97 logger.error(f"Missing key in Confluence spaces data: {str(e)}")

98 except ValueError as e:

99 logger.error(f"Invalid value in Confluence spaces: {str(e)}")

100 except TypeError as e:

101 logger.error(f"Type error when processing Confluence spaces: {str(e)}")

102 except Exception as e: # noqa: BLE001 - Intentional fallback with logging

103 logger.error(f"Unexpected error fetching Confluence spaces: {str(e)}")

104 logger.debug("Full exception details for Confluence spaces:", exc_info=True)

105

106 # Add Jira projects the user is involved with

107 if jira_fetcher:

108 try:

109 # Get current user's account ID

110 account_id = jira_fetcher.get_current_user_account_id()

111

112 # Use JQL to find issues the user is assigned to or reported

113 jql = f"assignee = {account_id} OR reporter = {account_id} ORDER BY updated DESC"

114 issues = jira_fetcher.jira.jql(jql, limit=250, fields=["project"])

115

116 # Extract and deduplicate projects

117 projects = {}

118 for issue in issues.get("issues", []):

119 project = issue.get("fields", {}).get("project", {})

120 project_key = project.get("key")

121 if project_key and project_key not in projects:

122 projects[project_key] = {

123 "key": project_key,

124 "name": project.get("name", project_key),

125 "description": project.get("description", ""),

126 }

127

128 # Add projects to resources

129 resources.extend(

130 [

131 Resource(

132 uri=AnyUrl(f"jira://{project['key']}"),

133 name=f"Jira Project: {project['name']}",

134 mimeType="text/plain",

135 description=(

136 f"A Jira project tracking issues and tasks. Project Key: {project['key']}. "

137 ).strip(),

138 )

139 for project in projects.values()

140 ]

141 )

142 except KeyError as e:

143 logger.error(f"Missing key in Jira projects data: {str(e)}")

144 except ValueError as e:

145 logger.error(f"Invalid value in Jira projects: {str(e)}")

146 except TypeError as e:

147 logger.error(f"Type error when processing Jira projects: {str(e)}")

148 except Exception as e: # noqa: BLE001 - Intentional fallback with logging

149 logger.error(f"Unexpected error fetching Jira projects: {str(e)}")

150 logger.debug("Full exception details for Jira projects:", exc_info=True)

151

152 return resources

153

154

155@app.read_resource()

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

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

158 uri_str = str(uri)

159

160 # Handle Confluence resources

161 if uri_str.startswith("confluence://"):

162 return _handle_confluence_resource(uri_str)

163

164 # Handle Jira resources

165 elif uri_str.startswith("jira://"):

166 return _handle_jira_resource(uri_str)

167

168 # Invalid resource URI

169 error_msg = f"Invalid resource URI: {uri}"

170 raise ValueError(error_msg)

171

172

173 def _handle_confluence_resource(uri_str: str) -> str:

174 """

175 Handle reading Confluence resources.

176

177 Args:

178 uri_str: The URI string for the Confluence resource

179

180 Returns:

181 The content of the resource

182

183 Raises:

184 ValueError: If Confluence is not configured or the resource is not found

185 """

186 if not services["confluence"]:

187 error_msg = (

188 "Confluence is not configured. Please provide Confluence credentials."

189 )

190 raise ValueError(error_msg)

191

192 parts = uri_str.replace("confluence://", "").split("/")

193

194 # Handle space listing

195 if len(parts) == 1:

196 return _handle_confluence_space(parts[0])

197

198 # Handle specific page

199 elif len(parts) >= 3 and parts[1] == "pages":

200 return _handle_confluence_page(parts[0], parts[2])

201

202 # Invalid Confluence resource

203 error_msg = f"Invalid Confluence resource URI: {uri_str}"

204 raise ValueError(error_msg)

205

206

207 def _handle_confluence_space(space_key: str) -> str:

208 """

209 Handle reading a Confluence space.

210

211 Args:

212 space_key: The key of the space to read

213

214 Returns:

215 Formatted content of pages in the space

216 """

217 # Use CQL to find recently updated pages in this space

218 cql = f'space = "{space_key}" AND contributor = currentUser() ORDER BY lastmodified DESC'

219 documents = confluence_fetcher.search(cql=cql, limit=20)

220

221 if not documents:

222 # Fallback to regular space pages if no user-contributed pages found

223 documents = confluence_fetcher.get_space_pages(

224 space_key, limit=10, convert_to_markdown=True

225 )

226

227 content = []

228 for doc in documents:

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

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

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

232

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

234

235

236 def _handle_confluence_page(space_key: str, title: str) -> str:

237 """

238 Handle reading a specific Confluence page.

239

240 Args:

241 space_key: The key of the space containing the page

242 title: The title of the page to read

243

244 Returns:

245 Content of the page

246

247 Raises:

248 ValueError: If the page is not found

249 """

250 doc = confluence_fetcher.get_page_by_title(space_key, title)

251 if not doc:

252 error_msg = f"Page not found: {title}"

253 raise ValueError(error_msg)

254 return doc.page_content

255

256

257 def _handle_jira_resource(uri_str: str) -> str:

258 """

259 Handle reading Jira resources.

260

261 Args:

262 uri_str: The URI string for the Jira resource

263

264 Returns:

265 The content of the resource

266

267 Raises:

268 ValueError: If Jira is not configured or the resource is not found

269 """

270 if not services["jira"]:

271 error_msg = "Jira is not configured. Please provide Jira credentials."

272 raise ValueError(error_msg)

273

274 parts = uri_str.replace("jira://", "").split("/")

275

276 # Handle project listing

277 if len(parts) == 1:

278 return _handle_jira_project(parts[0])

279

280 # Handle specific issue

281 elif len(parts) >= 3 and parts[1] == "issues":

282 return _handle_jira_issue(parts[2])

283

284 # Invalid Jira resource

285 error_msg = f"Invalid Jira resource URI: {uri_str}"

286 raise ValueError(error_msg)

287

288

289 def _handle_jira_project(project_key: str) -> str:

290 """

291 Handle reading a Jira project.

292

293 Args:

294 project_key: The key of the project to read

295

296 Returns:

297 Formatted content of issues in the project

298 """

299 # Get current user's account ID

300 account_id = jira_fetcher.get_current_user_account_id()

301

302 # Use JQL to find issues in this project that the user is involved with

303 jql = f"project = {project_key} AND (assignee = {account_id} OR reporter = {account_id}) ORDER BY updated DESC"

304 issues = jira_fetcher.search_issues(jql=jql, limit=20)

305

306 if not issues:

307 # Fallback to recent issues if no user-related issues found

308 issues = jira_fetcher.get_project_issues(project_key, limit=10)

309

310 content = []

311 for issue in issues:

312 key = issue.metadata.get("key", "")

313 title = issue.metadata.get("title", "")

314 url = issue.metadata.get("url", "")

315 status = issue.metadata.get("status", "")

316 content.append(

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

318 )

319

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

321

322

323 def _handle_jira_issue(issue_key: str) -> str:

324 """

325 Handle reading a specific Jira issue.

326

327 Args:

328 issue_key: The key of the issue to read

329

330 Returns:

331 Content of the issue

332 """

333 issue = jira_fetcher.get_issue(issue_key)

334 return issue.page_content

335

336

337@app.list_tools()

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

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

340 tools = []

341

342 if confluence_fetcher:

343 tools.extend(

344 [

345 Tool(

346 name="confluence_search",

347 description="Search Confluence content using CQL",

348 inputSchema={

349 "type": "object",

350 "properties": {

351 "query": {

352 "type": "string",

353 "description": "CQL query string (e.g. 'type=page AND space=DEV')",

354 },

355 "limit": {

356 "type": "number",

357 "description": "Maximum number of results (1-50)",

358 "default": 10,

359 "minimum": 1,

360 "maximum": 50,

361 },

362 },

363 "required": ["query"],

364 },

365 ),

366 Tool(

367 name="confluence_get_page",

368 description="Get content of a specific Confluence page by ID",

369 inputSchema={

370 "type": "object",

371 "properties": {

372 "page_id": {

373 "type": "string",

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

375 },

376 "include_metadata": {

377 "type": "boolean",

378 "description": "Whether to include page metadata",

379 "default": True,

380 },

381 },

382 "required": ["page_id"],

383 },

384 ),

385 Tool(

386 name="confluence_get_comments",

387 description="Get comments for a specific Confluence page",

388 inputSchema={

389 "type": "object",

390 "properties": {

391 "page_id": {

392 "type": "string",

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

394 }

395 },

396 "required": ["page_id"],

397 },

398 ),

399 Tool(

400 name="confluence_create_page",

401 description="Create a new Confluence page",

402 inputSchema={

403 "type": "object",

404 "properties": {

405 "space_key": {

406 "type": "string",

407 "description": "The key of the space to create the page in",

408 },

409 "title": {

410 "type": "string",

411 "description": "The title of the page",

412 },

413 "content": {

414 "type": "string",

415 "description": "The content of the page in Markdown format",

416 },

417 "parent_id": {

418 "type": "string",

419 "description": "Optional parent page ID",

420 },

421 },

422 "required": ["space_key", "title", "content"],

423 },

424 ),

425 Tool(

426 name="confluence_update_page",

427 description="Update an existing Confluence page",

428 inputSchema={

429 "type": "object",

430 "properties": {

431 "page_id": {

432 "type": "string",

433 "description": "The ID of the page to update",

434 },

435 "title": {

436 "type": "string",

437 "description": "The new title of the page",

438 },

439 "content": {

440 "type": "string",

441 "description": "The new content of the page in Markdown format",

442 },

443 "minor_edit": {

444 "type": "boolean",

445 "description": "Whether this is a minor edit",

446 "default": False,

447 },

448 "version_comment": {

449 "type": "string",

450 "description": "Optional comment for this version",

451 "default": "",

452 },

453 },

454 "required": ["page_id", "title", "content"],

455 },

456 ),

457 ]

458 )

459

460 if jira_fetcher:

461 tools.extend(

462 [

463 Tool(

464 name="jira_get_issue",

465 description="Get details of a specific Jira issue including its Epic links and relationship information",

466 inputSchema={

467 "type": "object",

468 "properties": {

469 "issue_key": {

470 "type": "string",

471 "description": "Jira issue key (e.g., 'PROJ-123')",

472 },

473 "expand": {

474 "type": "string",

475 "description": "Optional fields to expand. Examples: 'renderedFields' (for rendered content), 'transitions' (for available status transitions), 'changelog' (for history)",

476 "default": None,

477 },

478 "comment_limit": {

479 "type": "integer",

480 "description": "Maximum number of comments to include (0 or null for no comments)",

481 "minimum": 0,

482 "maximum": 100,

483 "default": None,

484 },

485 },

486 "required": ["issue_key"],

487 },

488 ),

489 Tool(

490 name="jira_search",

491 description="Search Jira issues using JQL (Jira Query Language)",

492 inputSchema={

493 "type": "object",

494 "properties": {

495 "jql": {

496 "type": "string",

497 "description": "JQL query string. Examples:\n"

498 '- Find Epics: "issuetype = Epic AND project = PROJ"\n'

499 '- Find issues in Epic: "parent = PROJ-123"\n'

500 "- Find by status: \"status = 'In Progress' AND project = PROJ\"\n"

501 '- Find by assignee: "assignee = currentUser()"\n'

502 '- Find recently updated: "updated >= -7d AND project = PROJ"\n'

503 '- Find by label: "labels = frontend AND project = PROJ"',

504 },

505 "fields": {

506 "type": "string",

507 "description": "Comma-separated fields to return",

508 "default": "*all",

509 },

510 "limit": {

511 "type": "number",

512 "description": "Maximum number of results (1-50)",

513 "default": 10,

514 "minimum": 1,

515 "maximum": 50,

516 },

517 },

518 "required": ["jql"],

519 },

520 ),

521 Tool(

522 name="jira_get_project_issues",

523 description="Get all issues for a specific Jira project",

524 inputSchema={

525 "type": "object",

526 "properties": {

527 "project_key": {

528 "type": "string",

529 "description": "The project key",

530 },

531 "limit": {

532 "type": "number",

533 "description": "Maximum number of results (1-50)",

534 "default": 10,

535 "minimum": 1,

536 "maximum": 50,

537 },

538 },

539 "required": ["project_key"],

540 },

541 ),

542 Tool(

543 name="jira_create_issue",

544 description="Create a new Jira issue with optional Epic link",

545 inputSchema={

546 "type": "object",

547 "properties": {

548 "project_key": {

549 "type": "string",

550 "description": "The JIRA project key (e.g. 'PROJ'). Never assume what it might be, always ask the user.",

551 },

552 "summary": {

553 "type": "string",

554 "description": "Summary/title of the issue",

555 },

556 "issue_type": {

557 "type": "string",

558 "description": "Issue type (e.g. 'Task', 'Bug', 'Story')",

559 },

560 "assignee": {

561 "type": "string",

562 "description": "Assignee of the ticket (accountID, full name or e-mail)",

563 },

564 "description": {

565 "type": "string",

566 "description": "Issue description",

567 "default": "",

568 },

569 "additional_fields": {

570 "type": "string",

571 "description": "Optional JSON string of additional fields to set. Examples:\n"

572 '- Link to Epic: {"parent": {"key": "PROJ-123"}} - For linking to an Epic after creation, prefer using the jira_link_to_epic tool instead\n'

573 '- Set priority: {"priority": {"name": "High"}} or {"priority": null} for no priority (common values: High, Medium, Low, None)\n'

574 '- Add labels: {"labels": ["label1", "label2"]}\n'

575 '- Set due date: {"duedate": "2023-12-31"}\n'

576 '- Custom fields: {"customfield_10XXX": "value"}',

577 "default": "{}",

578 },

579 },

580 "required": ["project_key", "summary", "issue_type"],

581 },

582 ),

583 Tool(

584 name="jira_update_issue",

585 description="Update an existing Jira issue including changing status, adding Epic links, updating fields, etc.",

586 inputSchema={

587 "type": "object",

588 "properties": {

589 "issue_key": {

590 "type": "string",

591 "description": "Jira issue key (e.g., 'PROJ-123')",

592 },

593 "fields": {

594 "type": "string",

595 "description": "A valid JSON object of fields to update. Examples:\n"

596 '- Add to Epic: {"parent": {"key": "PROJ-456"}} - Prefer using the dedicated jira_link_to_epic tool instead\n'

597 '- Change assignee: {"assignee": "user@email.com"} or {"assignee": null} to unassign\n'

598 '- Update summary: {"summary": "New title"}\n'

599 '- Update description: {"description": "New description"}\n'

600 "- Change status: requires transition IDs - use jira_get_transitions and jira_transition_issue instead\n"

601 '- Add labels: {"labels": ["label1", "label2"]}\n'

602 '- Set priority: {"priority": {"name": "High"}} or {"priority": null} for no priority (common values: High, Medium, Low, None)\n'

603 '- Update custom fields: {"customfield_10XXX": "value"}',

604 },

605 "additional_fields": {

606 "type": "string",

607 "description": "Optional JSON string of additional fields to update",

608 "default": "{}",

609 },

610 },

611 "required": ["issue_key", "fields"],

612 },

613 ),

614 Tool(

615 name="jira_delete_issue",

616 description="Delete an existing Jira issue",

617 inputSchema={

618 "type": "object",

619 "properties": {

620 "issue_key": {

621 "type": "string",

622 "description": "Jira issue key (e.g. PROJ-123)",

623 },

624 },

625 "required": ["issue_key"],

626 },

627 ),

628 Tool(

629 name="jira_add_comment",

630 description="Add a comment to a Jira issue",

631 inputSchema={

632 "type": "object",

633 "properties": {

634 "issue_key": {

635 "type": "string",

636 "description": "Jira issue key (e.g., 'PROJ-123')",

637 },

638 "comment": {

639 "type": "string",

640 "description": "Comment text in Markdown format",

641 },

642 },

643 "required": ["issue_key", "comment"],

644 },

645 ),

646 Tool(

647 name="jira_add_worklog",

648 description="Add a worklog entry to a Jira issue",

649 inputSchema={

650 "type": "object",

651 "properties": {

652 "issue_key": {

653 "type": "string",

654 "description": "Jira issue key (e.g., 'PROJ-123')",

655 },

656 "time_spent": {

657 "type": "string",

658 "description": "Time spent in Jira format (e.g., '1h 30m', '1d', '30m')",

659 },

660 "comment": {

661 "type": "string",

662 "description": "Optional comment for the worklog in Markdown format",

663 },

664 "started": {

665 "type": "string",

666 "description": "Optional start time in ISO format (e.g. '2023-08-01T12:00:00.000+0000'). If not provided, current time will be used.",

667 },

668 "original_estimate": {

669 "type": "string",

670 "description": "Optional original estimate in Jira format (e.g., '1h 30m', '1d'). This will update the original estimate for the issue.",

671 },

672 "remaining_estimate": {

673 "type": "string",

674 "description": "Optional remaining estimate in Jira format (e.g., '1h', '30m'). This will update the remaining estimate for the issue.",

675 },

676 },

677 "required": ["issue_key", "time_spent"],

678 },

679 ),

680 Tool(

681 name="jira_get_worklog",

682 description="Get worklog entries for a Jira issue",

683 inputSchema={

684 "type": "object",

685 "properties": {

686 "issue_key": {

687 "type": "string",

688 "description": "Jira issue key (e.g., 'PROJ-123')",

689 },

690 },

691 "required": ["issue_key"],

692 },

693 ),

694 Tool(

695 name="jira_link_to_epic",

696 description="Link an existing issue to an epic",

697 inputSchema={

698 "type": "object",

699 "properties": {

700 "issue_key": {

701 "type": "string",

702 "description": "The key of the issue to link (e.g., 'PROJ-123')",

703 },

704 "epic_key": {

705 "type": "string",

706 "description": "The key of the epic to link to (e.g., 'PROJ-456')",

707 },

708 },

709 "required": ["issue_key", "epic_key"],

710 },

711 ),

712 Tool(

713 name="jira_get_epic_issues",

714 description="Get all issues linked to a specific epic",

715 inputSchema={

716 "type": "object",

717 "properties": {

718 "epic_key": {

719 "type": "string",

720 "description": "The key of the epic (e.g., 'PROJ-123')",

721 },

722 "limit": {

723 "type": "number",

724 "description": "Maximum number of issues to return (1-50)",

725 "default": 10,

726 "minimum": 1,

727 "maximum": 50,

728 },

729 },

730 "required": ["epic_key"],

731 },

732 ),

733 Tool(

734 name="jira_get_transitions",

735 description="Get available status transitions for a Jira issue",

736 inputSchema={

737 "type": "object",

738 "properties": {

739 "issue_key": {

740 "type": "string",

741 "description": "Jira issue key (e.g., 'PROJ-123')",

742 },

743 },

744 "required": ["issue_key"],

745 },

746 ),

747 Tool(

748 name="jira_transition_issue",

749 description="Transition a Jira issue to a new status",

750 inputSchema={

751 "type": "object",

752 "properties": {

753 "issue_key": {

754 "type": "string",

755 "description": "Jira issue key (e.g., 'PROJ-123')",

756 },

757 "transition_id": {

758 "type": "string",

759 "description": "ID of the transition to perform (get this from jira_get_transitions)",

760 },

761 "fields": {

762 "type": "string",

763 "description": "JSON string of fields to update during the transition (optional)",

764 "default": "{}",

765 },

766 "comment": {

767 "type": "string",

768 "description": "Comment to add during the transition (optional)",

769 },

770 },

771 "required": ["issue_key", "transition_id"],

772 },

773 ),

774 ]

775 )

776

777 return tools

778

779

780@app.call_tool()

781 async def call_tool(name: str, arguments: dict[str, Any]) -> Sequence[TextContent]:

782 """Handle tool calls for Confluence and Jira operations."""

783 try:

784 # Helper functions for formatting results

785 def format_comment(comment: dict) -> dict:

786 """

787 Format a Jira comment for display.

788

789 Args:

790 comment: The raw comment dictionary from Jira

791

792 Returns:

793 Formatted comment dictionary with selected fields

794 """

795 return {

796 "id": comment.get("id"),

797 "author": comment.get("author", {}).get("displayName", "Unknown"),

798 "created": comment.get("created"),

799 "body": comment.get("body"),

800 }

801

802 def format_issue(doc: Document) -> dict:

803 """

804 Format a Jira issue document for display.

805

806 Args:

807 doc: The Document object containing issue data

808

809 Returns:

810 Formatted issue dictionary with selected fields

811 """

812 return {

813 "key": doc.metadata.get("key", ""),

814 "title": doc.metadata.get("title", ""),

815 "type": doc.metadata.get("type", "Unknown"),

816 "status": doc.metadata.get("status", "Unknown"),

817 "created_date": doc.metadata.get("created_date", ""),

818 "priority": doc.metadata.get("priority", "None"),

819 "link": doc.metadata.get("link", ""),

820 }

821

822 def format_transition(transition: dict) -> dict:

823 """

824 Format a Jira transition for display.

825

826 Args:

827 transition: The raw transition dictionary from Jira

828

829 Returns:

830 Formatted transition dictionary with selected fields

831 """

832 return {

833 "id": transition.get("id"),

834 "name": transition.get("name"),

835 "to_status": transition.get("to", {}).get("name"),

836 }

837

838 # Dispatch to the appropriate handler based on the tool name

839 tool_handlers = {

840 # Confluence tools

841 "confluence_search": handle_confluence_search,

842 "confluence_get_page": handle_confluence_get_page,

843 "confluence_get_comments": handle_confluence_get_comments,

844 "confluence_create_page": handle_confluence_create_page,

845 "confluence_update_page": handle_confluence_update_page,

846 # Jira tools

847 "jira_get_issue": handle_jira_get_issue,

848 "jira_search": handle_jira_search,

849 "jira_get_project_issues": handle_jira_get_project_issues,

850 "jira_create_issue": handle_jira_create_issue,

851 "jira_update_issue": handle_jira_update_issue,

852 "jira_delete_issue": handle_jira_delete_issue,

853 "jira_add_comment": handle_jira_add_comment,

854 "jira_add_worklog": handle_jira_add_worklog,

855 "jira_get_worklog": handle_jira_get_worklog,

856 "jira_get_transitions": handle_jira_get_transitions,

857 "jira_transition_issue": handle_jira_transition_issue,

858 "jira_link_to_epic": handle_jira_link_to_epic,

859 "jira_get_epic_issues": handle_jira_get_epic_issues,

860 }

861

862 if name in tool_handlers:

863 return tool_handlers[name](

864 arguments, format_comment, format_issue, format_transition

865 )

866 else:

867 error_msg = f"Unsupported tool: {name}"

868 logger.error(error_msg)

869 return [TextContent(type="error", text=error_msg)]

870

871 except KeyError as e:

872 error_msg = f"Missing required parameter for tool {name}: {str(e)}"

873 logger.error(error_msg)

874 return [TextContent(type="error", text=error_msg)]

875 except (ValueError, TypeError) as e:

876 error_msg = f"Invalid parameter value for tool {name}: {str(e)}"

877 logger.error(error_msg)

878 return [TextContent(type="error", text=error_msg)]

879 except AttributeError as e:

880 error_msg = f"Tool execution error for {name}: {str(e)}"

881 logger.error(error_msg)

882 return [TextContent(type="error", text=error_msg)]

883 except Exception as e: # noqa: BLE001 - Intentional fallback with logging

884 error_msg = f"Unexpected error executing tool {name}: {str(e)}"

885 logger.error(error_msg)

886 logger.debug(f"Full exception details for tool {name}:", exc_info=True)

887 return [TextContent(type="error", text=error_msg)]

888

889

890 def handle_confluence_search(

891 arguments: dict[str, Any],

892 format_comment: CommentFormatter,

893 format_issue: IssueFormatter,

894 format_transition: TransitionFormatter,

895) -> Sequence[TextContent]:

896 """

897 Handle confluence_search tool.

898

899 Args:

900 arguments: The tool arguments

901 format_comment: Helper function for formatting comments

902 format_issue: Helper function for formatting issues

903 format_transition: Helper function for formatting transitions

904

905 Returns:

906 Search results

907 """

908 # Ensure Confluence is configured

909 if confluence_fetcher is None:

910 return [

911 TextContent(

912 text="Confluence is not configured. Please set CONFLUENCE_URL, CONFLUENCE_USERNAME, and CONFLUENCE_API_TOKEN environment variables.",

913 type="text",

914 )

915 ]

916

917 query = arguments["query"]

918 limit = arguments.get("limit", 10)

919

920 try:

921 results = confluence_fetcher.search(query, limit=limit)

922 return [TextContent(type="text", text=results)]

923 except KeyError as e:

924 error_msg = f"Missing key in search parameters or results: {str(e)}"

925 logger.error(error_msg)

926 return [TextContent(type="text", text=error_msg)]

927 except ValueError as e:

928 error_msg = f"Invalid search parameter: {str(e)}"

929 logger.error(error_msg)

930 return [TextContent(type="text", text=error_msg)]

931 except Exception as e: # noqa: BLE001 - Intentional fallback with logging

932 error_msg = f"Unexpected error searching Confluence: {str(e)}"

933 logger.error(error_msg)

934 logger.debug("Full exception details for Confluence search:", exc_info=True)

935 return [TextContent(type="text", text=error_msg)]

936

937

938 def handle_confluence_get_page(

939 arguments: dict[str, Any],

940 format_comment: CommentFormatter,

941 format_issue: IssueFormatter,

942 format_transition: TransitionFormatter,

943) -> Sequence[TextContent]:

944 """

945 Handle confluence_get_page tool.

946

947 Args:

948 arguments: The tool arguments

949 format_comment: Helper function for formatting comments

950 format_issue: Helper function for formatting issues

951 format_transition: Helper function for formatting transitions

952

953 Returns:

954 Formatted page content

955 """

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

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

958

959 if include_metadata:

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

961 else:

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

963

964 return [

965 TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))

966 ]

967

968

969 def handle_confluence_get_comments(

970 arguments: dict[str, Any],

971 format_comment: CommentFormatter,

972 format_issue: IssueFormatter,

973 format_transition: TransitionFormatter,

974) -> Sequence[TextContent]:

975 """

976 Handle confluence_get_comments tool.

977

978 Args:

979 arguments: The tool arguments

980 format_comment: Helper function for formatting comments

981 format_issue: Helper function for formatting issues

982 format_transition: Helper function for formatting transitions

983

984 Returns:

985 Formatted comments

986 """

987 comments = confluence_fetcher.get_page_comments(

988 page_id=arguments["page_id"], return_markdown=True

989 )

990 # Convert Document objects to dictionaries for the formatter

991 formatted_comments = [format_comment(doc.metadata) for doc in comments]

992

993 return [

994 TextContent(

995 type="text",

996 text=json.dumps(formatted_comments, indent=2, ensure_ascii=False),

997 )

998 ]

999

1000

1001 def handle_confluence_create_page(

1002 arguments: dict[str, Any],

1003 format_comment: CommentFormatter,

1004 format_issue: IssueFormatter,

1005 format_transition: TransitionFormatter,

1006) -> Sequence[TextContent]:

1007 """

1008 Handle confluence_create_page tool.

1009

1010 Args:

1011 arguments: The tool arguments

1012 format_comment: Helper function for formatting comments

1013 format_issue: Helper function for formatting issues

1014 format_transition: Helper function for formatting transitions

1015

1016 Returns:

1017 Formatted page creation result

1018 """

1019 # Convert markdown content to HTML storage format

1020 space_key = arguments["space_key"]

1021 title = arguments["title"]

1022 content = arguments["content"]

1023 parent_id = arguments.get("parent_id")

1024

1025 # Convert markdown to Confluence storage format

1026 storage_format = markdown_to_confluence_storage(content)

1027

1028 # Handle parent_id - convert to string if not None

1029 parent_id_str: str | None = str(parent_id) if parent_id is not None else None

1030

1031 # Create the page

1032 doc = confluence_fetcher.create_page(

1033 space_key=space_key,

1034 title=title,

1035 body=storage_format, # Now using the converted storage format

1036 parent_id=parent_id_str,

1037 )

1038

1039 result = {

1040 "page_id": doc.metadata["page_id"],

1041 "title": doc.metadata["title"],

1042 "space_key": doc.metadata["space_key"],

1043 "url": doc.metadata["url"],

1044 "version": doc.metadata["version"],

1045 "content": doc.page_content[:500] + "..."

1046 if len(doc.page_content) > 500

1047 else doc.page_content,

1048 }

1049

1050 return [

1051 TextContent(

1052 type="text",

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

1054 )

1055 ]

1056

1057

1058 def handle_confluence_update_page(

1059 arguments: dict[str, Any],

1060 format_comment: CommentFormatter,

1061 format_issue: IssueFormatter,

1062 format_transition: TransitionFormatter,

1063) -> Sequence[TextContent]:

1064 """

1065 Handle confluence_update_page tool.

1066

1067 Args:

1068 arguments: The tool arguments

1069 format_comment: Helper function for formatting comments

1070 format_issue: Helper function for formatting issues

1071 format_transition: Helper function for formatting transitions

1072

1073 Returns:

1074 Formatted page update result

1075 """

1076 page_id = arguments["page_id"]

1077 title = arguments["title"]

1078 content = arguments["content"]

1079 minor_edit = arguments.get("minor_edit", False)

1080 version_comment = arguments.get("version_comment", "")

1081

1082 # Convert markdown to Confluence storage format

1083 storage_format = markdown_to_confluence_storage(content)

1084

1085 # Update the page

1086 doc = confluence_fetcher.update_page(

1087 page_id=page_id,

1088 title=title,

1089 body=storage_format,

1090 is_minor_edit=minor_edit,

1091 version_comment=version_comment,

1092 )

1093

1094 result = {

1095 "page_id": doc.metadata["page_id"],

1096 "title": doc.metadata["title"],

1097 "space_key": doc.metadata["space_key"],

1098 "url": doc.metadata["url"],

1099 "version": doc.metadata["version"],

1100 "content": doc.page_content[:500] + "..."

1101 if len(doc.page_content) > 500

1102 else doc.page_content,

1103 }

1104

1105 return [

1106 TextContent(

1107 type="text",

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

1109 )

1110 ]

1111

1112

1113 def handle_jira_get_issue(

1114 arguments: dict[str, Any],

1115 format_comment: CommentFormatter,

1116 format_issue: IssueFormatter,

1117 format_transition: TransitionFormatter,

1118) -> Sequence[TextContent]:

1119 """

1120 Handle jira_get_issue tool.

1121

1122 Args:

1123 arguments: The tool arguments

1124 format_comment: Helper function for formatting comments

1125 format_issue: Helper function for formatting issues

1126 format_transition: Helper function for formatting transitions

1127

1128 Returns:

1129 Issue details

1130 """

1131 # Ensure Jira is configured

1132 if jira_fetcher is None:

1133 return [

1134 TextContent(

1135 text="Jira is not configured. Please set JIRA_URL, JIRA_USERNAME, and JIRA_API_TOKEN environment variables.",

1136 type="text",

1137 )

1138 ]

1139

1140 issue_key = arguments["issue_key"]

1141 expand = arguments.get("expand")

1142 comment_limit = arguments.get("comment_limit", 10)

1143

1144 try:

1145 doc = jira_fetcher.get_issue(

1146 issue_key, expand=expand, comment_limit=comment_limit

1147 )

1148 return [TextContent(type="text", text=format_issue(doc))]

1149 except KeyError as e:

1150 error_msg = f"Missing key in Jira issue {issue_key}: {str(e)}"

1151 logger.error(error_msg)

1152 return [TextContent(type="text", text=error_msg)]

1153 except ValueError as e:

1154 error_msg = f"Invalid parameter for Jira issue {issue_key}: {str(e)}"

1155 logger.error(error_msg)

1156 return [TextContent(type="text", text=error_msg)]

1157 except Exception as e: # noqa: BLE001 - Intentional fallback with logging

1158 error_msg = f"Unexpected error getting Jira issue {issue_key}: {str(e)}"

1159 logger.error(error_msg)

1160 logger.debug(

1161 f"Full exception details for Jira issue {issue_key}:", exc_info=True

1162 )

1163 return [TextContent(type="text", text=error_msg)]

1164

1165

1166 def handle_jira_search(

1167 arguments: dict[str, Any],

1168 format_comment: CommentFormatter,

1169 format_issue: IssueFormatter,

1170 format_transition: TransitionFormatter,

1171) -> Sequence[TextContent]:

1172 """

1173 Handle jira_search tool.

1174

1175 Args:

1176 arguments: The tool arguments

1177 format_comment: Helper function for formatting comments

1178 format_issue: Helper function for formatting issues

1179 format_transition: Helper function for formatting transitions

1180

1181 Returns:

1182 Formatted search results

1183 """

1184 jql = arguments["query"]

1185 limit = min(int(arguments.get("limit", 10)), 50)

1186 start = int(arguments.get("start", 0))

1187

1188 documents = jira_fetcher.search_issues(jql=jql, limit=limit, start=start)

1189 results = [format_issue(doc) for doc in documents]

1190

1191 return [

1192 TextContent(type="text", text=json.dumps(results, indent=2, ensure_ascii=False))

1193 ]

1194

1195

1196 def handle_jira_get_project_issues(

1197 arguments: dict[str, Any],

1198 format_comment: CommentFormatter,

1199 format_issue: IssueFormatter,

1200 format_transition: TransitionFormatter,

1201) -> Sequence[TextContent]:

1202 """

1203 Handle jira_get_project_issues tool.

1204

1205 Args:

1206 arguments: The tool arguments

1207 format_comment: Helper function for formatting comments

1208 format_issue: Helper function for formatting issues

1209 format_transition: Helper function for formatting transitions

1210

1211 Returns:

1212 Formatted project issues

1213 """

1214 project_key = arguments["project_key"]

1215 limit = min(int(arguments.get("limit", 10)), 50)

1216 start = int(arguments.get("start", 0))

1217

1218 documents = jira_fetcher.get_project_issues(

1219 project_key=project_key, limit=limit, start=start

1220 )

1221 results = [format_issue(doc) for doc in documents]

1222

1223 return [

1224 TextContent(type="text", text=json.dumps(results, indent=2, ensure_ascii=False))

1225 ]

1226

1227

1228 def handle_jira_create_issue(

1229 arguments: dict[str, Any],

1230 format_comment: CommentFormatter,

1231 format_issue: IssueFormatter,

1232 format_transition: TransitionFormatter,

1233) -> Sequence[TextContent]:

1234 """

1235 Handle jira_create_issue tool.

1236

1237 Args:

1238 arguments: The tool arguments

1239 format_comment: Helper function for formatting comments

1240 format_issue: Helper function for formatting issues

1241 format_transition: Helper function for formatting transitions

1242

1243 Returns:

1244 Formatted issue creation result

1245 """

1246 # Extract required arguments

1247 project_key = arguments["project_key"]

1248 summary = arguments["summary"]

1249 issue_type = arguments["issue_type"]

1250

1251 # Extract optional arguments

1252 description = arguments.get("description", "")

1253 assignee = arguments.get("assignee")

1254

1255 # Create a shallow copy of arguments without the standard fields

1256 # to pass any remaining ones as custom fields

1257 custom_fields = arguments.copy()

1258 for field in ["project_key", "summary", "issue_type", "description", "assignee"]:

1259 custom_fields.pop(field, None)

1260

1261 # Create the issue

1262 doc = jira_fetcher.create_issue(

1263 project_key=project_key,

1264 summary=summary,

1265 issue_type=issue_type,

1266 description=description,

1267 assignee=assignee,

1268 **custom_fields,

1269 )

1270

1271 result = format_issue(doc)

1272 result["description"] = doc.page_content

1273

1274 return [

1275 TextContent(

1276 type="text",

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

1278 )

1279 ]

1280

1281

1282 def handle_jira_update_issue(

1283 arguments: dict[str, Any],

1284 format_comment: CommentFormatter,

1285 format_issue: IssueFormatter,

1286 format_transition: TransitionFormatter,

1287) -> Sequence[TextContent]:

1288 """

1289 Handle jira_update_issue tool.

1290

1291 Args:

1292 arguments: The tool arguments

1293 format_comment: Helper function for formatting comments

1294 format_issue: Helper function for formatting issues

1295 format_transition: Helper function for formatting transitions

1296

1297 Returns:

1298 Formatted issue update result

1299 """

1300 # Extract issue key

1301 issue_key = arguments["issue_key"]

1302

1303 # Create a shallow copy of arguments without the issue_key

1304 fields = arguments.copy()

1305 fields.pop("issue_key", None)

1306

1307 # Update the issue

1308 doc = jira_fetcher.update_issue(issue_key=issue_key, **fields)

1309

1310 result = format_issue(doc)

1311 result["description"] = doc.page_content

1312

1313 return [

1314 TextContent(

1315 type="text",

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

1317 )

1318 ]

1319

1320

1321 def handle_jira_delete_issue(

1322 arguments: dict[str, Any],

1323 format_comment: CommentFormatter,

1324 format_issue: IssueFormatter,

1325 format_transition: TransitionFormatter,

1326) -> Sequence[TextContent]:

1327 """

1328 Handle jira_delete_issue tool.

1329

1330 Args:

1331 arguments: The tool arguments

1332 format_comment: Helper function for formatting comments

1333 format_issue: Helper function for formatting issues

1334 format_transition: Helper function for formatting transitions

1335

1336 Returns:

1337 Deletion confirmation

1338 """

1339 issue_key = arguments["issue_key"]

1340 success = jira_fetcher.delete_issue(issue_key)

1341

1342 if success:

1343 return [

1344 TextContent(

1345 type="text",

1346 text=f"Issue {issue_key} deleted successfully.",

1347 )

1348 ]

1349 else:

1350 return [

1351 TextContent(

1352 type="error",

1353 text=f"Failed to delete issue {issue_key}.",

1354 )

1355 ]

1356

1357

1358 def handle_jira_add_comment(

1359 arguments: dict[str, Any],

1360 format_comment: CommentFormatter,

1361 format_issue: IssueFormatter,

1362 format_transition: TransitionFormatter,

1363) -> Sequence[TextContent]:

1364 """

1365 Handle jira_add_comment tool.

1366

1367 Args:

1368 arguments: The tool arguments

1369 format_comment: Helper function for formatting comments

1370 format_issue: Helper function for formatting issues

1371 format_transition: Helper function for formatting transitions

1372

1373 Returns:

1374 Comment addition confirmation

1375 """

1376 issue_key = arguments["issue_key"]

1377 comment_text = arguments["comment"]

1378

1379 result = jira_fetcher.add_comment(issue_key, comment_text)

1380 formatted_result = format_comment(result)

1381

1382 return [

1383 TextContent(

1384 type="text",

1385 text=f"Comment added successfully:\n{json.dumps(formatted_result, indent=2, ensure_ascii=False)}",

1386 )

1387 ]

1388

1389

1390 def handle_jira_add_worklog(

1391 arguments: dict[str, Any],

1392 format_comment: CommentFormatter,

1393 format_issue: IssueFormatter,

1394 format_transition: TransitionFormatter,

1395) -> Sequence[TextContent]:

1396 """

1397 Handle jira_add_worklog tool.

1398

1399 Args:

1400 arguments: The tool arguments

1401 format_comment: Helper function for formatting comments

1402 format_issue: Helper function for formatting issues

1403 format_transition: Helper function for formatting transitions

1404

1405 Returns:

1406 Worklog addition confirmation

1407 """

1408 # Ensure Jira is configured

1409 if jira_fetcher is None:

1410 return [

1411 TextContent(

1412 text="Jira is not configured. Please set JIRA_URL, JIRA_USERNAME, and JIRA_API_TOKEN environment variables.",

1413 type="text",

1414 )

1415 ]

1416

1417 issue_key = arguments["issue_key"]

1418 time_spent = arguments["time_spent"]

1419

1420 # Process optional parameters with proper type checking

1421 comment = arguments.get("comment")

1422 if comment is not None and not isinstance(comment, str):

1423 comment = str(comment)

1424

1425 started = arguments.get("started")

1426 if started is not None and not isinstance(started, str):

1427 started = str(started)

1428

1429 original_estimate = arguments.get("original_estimate")

1430 if original_estimate is not None and not isinstance(original_estimate, str):

1431 original_estimate = str(original_estimate)

1432

1433 remaining_estimate = arguments.get("remaining_estimate")

1434 if remaining_estimate is not None and not isinstance(remaining_estimate, str):

1435 remaining_estimate = str(remaining_estimate)

1436

1437 try:

1438 result = jira_fetcher.add_worklog(

1439 issue_key=issue_key,

1440 time_spent=time_spent,

1441 comment=comment,

1442 started=started,

1443 original_estimate=original_estimate,

1444 remaining_estimate=remaining_estimate,

1445 )

1446

1447 return [

1448 TextContent(

1449 type="text",

1450 text=f"Worklog added successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}",

1451 )

1452 ]

1453 except KeyError as e:

1454 error_msg = f"Missing required field for worklog on {issue_key}: {str(e)}"

1455 logger.error(error_msg)

1456 return [TextContent(type="text", text=error_msg)]

1457 except ValueError as e:

1458 error_msg = f"Invalid value for worklog parameter on {issue_key}: {str(e)}"

1459 logger.error(error_msg)

1460 return [TextContent(type="text", text=error_msg)]

1461 except Exception as e: # noqa: BLE001 - Intentional fallback with logging

1462 error_msg = f"Unexpected error adding worklog to {issue_key}: {str(e)}"

1463 logger.error(error_msg)

1464 logger.debug(

1465 f"Full exception details for worklog on {issue_key}:", exc_info=True

1466 )

1467 return [TextContent(type="text", text=error_msg)]

1468

1469

1470 def handle_jira_get_worklog(

1471 arguments: dict[str, Any],

1472 format_comment: CommentFormatter,

1473 format_issue: IssueFormatter,

1474 format_transition: TransitionFormatter,

1475) -> Sequence[TextContent]:

1476 """

1477 Handle jira_get_worklog tool.

1478

1479 Args:

1480 arguments: The tool arguments

1481 format_comment: Helper function for formatting comments

1482 format_issue: Helper function for formatting issues

1483 format_transition: Helper function for formatting transitions

1484

1485 Returns:

1486 Formatted worklog entries

1487 """

1488 issue_key = arguments["issue_key"]

1489 worklogs = jira_fetcher.get_worklogs(issue_key)

1490

1491 return [

1492 TextContent(

1493 type="text",

1494 text=json.dumps(worklogs, indent=2, ensure_ascii=False),

1495 )

1496 ]

1497

1498

1499 def handle_jira_get_transitions(

1500 arguments: dict[str, Any],

1501 format_comment: CommentFormatter,

1502 format_issue: IssueFormatter,

1503 format_transition: TransitionFormatter,

1504) -> Sequence[TextContent]:

1505 """

1506 Handle jira_get_transitions tool.

1507

1508 Args:

1509 arguments: The tool arguments

1510 format_comment: Helper function for formatting comments

1511 format_issue: Helper function for formatting issues

1512 format_transition: Helper function for formatting transitions

1513

1514 Returns:

1515 Formatted transition options

1516 """

1517 issue_key = arguments["issue_key"]

1518 transitions = jira_fetcher.get_available_transitions(issue_key)

1519 formatted_transitions = [format_transition(t) for t in transitions]

1520

1521 return [

1522 TextContent(

1523 type="text",

1524 text=json.dumps(formatted_transitions, indent=2, ensure_ascii=False),

1525 )

1526 ]

1527

1528

1529 def handle_jira_transition_issue(

1530 arguments: dict[str, Any],

1531 format_comment: CommentFormatter,

1532 format_issue: IssueFormatter,

1533 format_transition: TransitionFormatter,

1534) -> Sequence[TextContent]:

1535 """

1536 Handle jira_transition_issue tool.

1537

1538 Args:

1539 arguments: The tool arguments

1540 format_comment: Helper function for formatting comments

1541 format_issue: Helper function for formatting issues

1542 format_transition: Helper function for formatting transitions

1543

1544 Returns:

1545 Transition confirmation

1546 """

1547 issue_key = arguments["issue_key"]

1548 transition_id = arguments["transition_id"]

1549

1550 # Optional arguments

1551 fields = arguments.get("fields")

1552 comment = arguments.get("comment")

1553

1554 # Transition the issue

1555 doc = jira_fetcher.transition_issue(

1556 issue_key=issue_key,

1557 transition_id=transition_id,

1558 fields=fields,

1559 comment=comment,

1560 )

1561

1562 result = format_issue(doc)

1563 result["description"] = doc.page_content

1564

1565 return [

1566 TextContent(

1567 type="text",

1568 text=f"Issue transitioned successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}",

1569 )

1570 ]

1571

1572

1573 def handle_jira_link_to_epic(

1574 arguments: dict[str, Any],

1575 format_comment: CommentFormatter,

1576 format_issue: IssueFormatter,

1577 format_transition: TransitionFormatter,

1578) -> Sequence[TextContent]:

1579 """

1580 Handle jira_link_to_epic tool.

1581

1582 Args:

1583 arguments: The tool arguments

1584 format_comment: Helper function for formatting comments

1585 format_issue: Helper function for formatting issues

1586 format_transition: Helper function for formatting transitions

1587

1588 Returns:

1589 Link confirmation

1590 """

1591 issue_key = arguments["issue_key"]

1592 epic_key = arguments["epic_key"]

1593

1594 doc = jira_fetcher.link_issue_to_epic(issue_key, epic_key)

1595 result = format_issue(doc)

1596 result["description"] = doc.page_content

1597 result["epic_key"] = epic_key

1598

1599 return [

1600 TextContent(

1601 type="text",

1602 text=f"Issue linked to epic successfully:\n{json.dumps(result, indent=2, ensure_ascii=False)}",

1603 )

1604 ]

1605

1606

1607 def handle_jira_get_epic_issues(

1608 arguments: dict[str, Any],

1609 format_comment: CommentFormatter,

1610 format_issue: IssueFormatter,

1611 format_transition: TransitionFormatter,

1612) -> Sequence[TextContent]:

1613 """

1614 Handle jira_get_epic_issues tool.

1615

1616 Args:

1617 arguments: The tool arguments

1618 format_comment: Helper function for formatting comments

1619 format_issue: Helper function for formatting issues

1620 format_transition: Helper function for formatting transitions

1621

1622 Returns:

1623 Formatted epic issues

1624 """

1625 epic_key = arguments["epic_key"]

1626 limit = min(int(arguments.get("limit", 50)), 100)

1627

1628 documents = jira_fetcher.get_epic_issues(epic_key, limit)

1629 results = [format_issue(doc) for doc in documents]

1630

1631 return [

1632 TextContent(

1633 type="text",

1634 text=json.dumps(results, indent=2, ensure_ascii=False),

1635 )

1636 ]

1637

1638

1639 async def main() -> None:

1640 """

1641 Run the MCP server in stdio mode.

1642

1643 This function creates and runs the MCP server using the stdio interface,

1644 which enables communication with the MCP client through standard input/output.

1645

1646 Returns:

1647 None

1648 """

1649 # Import here to avoid issues with event loops

1650 from mcp.server.stdio import stdio_server

1651

1652 try:

1653 # Log the startup information

1654 logger.info("Starting MCP Atlassian server")

1655 if confluence_fetcher:

1656 logger.info(f"Confluence URL: {confluence_fetcher.config.url}")

1657 if jira_fetcher:

1658 logger.info(f"Jira URL: {jira_fetcher.config.url}")

1659

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

1661 await app.run(

1662 read_stream, write_stream, app.create_initialization_options()

1663 )

1664 except Exception as err:

1665 logger.error(f"Error running server: {err}")

1666 error_msg = f"Failed to run server: {err}"

1667 raise RuntimeError(error_msg) from err

1668

1669

1670 if __name__ == "__main__":

1671 import asyncio

1672

1673 asyncio.run(main())