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

380 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-10 03:26 +0900

1import json 

2import logging 

3import os 

4from collections.abc import Callable, Sequence 

5from typing import Any 

6 

7from mcp.server import Server 

8from mcp.types import Resource, TextContent, Tool 

9from pydantic import AnyUrl 

10 

11from .confluence import ConfluenceFetcher 

12from .document_types import Document 

13from .jira import JiraFetcher 

14from .preprocessing import markdown_to_confluence_storage 

15 

16# Configure logging 

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

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

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

25 

26# Type aliases for formatter functions 

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

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

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

30 

31 

32def 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 

62services = get_available_services() 

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

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

65app = Server("mcp-atlassian") 

66 

67 

68@app.list_resources() 

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

156async 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 

173def _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 

207def _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 

236def _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 

257def _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 

289def _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 

323def _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() 

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

781async 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 

890def 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 

938def 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 

969def 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 

1001def 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 

1058def 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 

1113def 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 

1166def 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 

1196def 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 

1228def 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 

1282def 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 

1321def 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 

1358def 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 

1390def 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 

1470def 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 

1499def 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 

1529def 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 

1573def 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 

1607def 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 

1639async 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 

1670if __name__ == "__main__": 

1671 import asyncio 

1672 

1673 asyncio.run(main())