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

267 statements  

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

1import json 

2import logging 

3import os 

4from collections.abc import 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 .jira import JiraFetcher 

13from .preprocessing import markdown_to_confluence_storage 

14 

15# Configure logging 

16logging.basicConfig( 

17 level=logging.INFO, 

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

19 filename="mcp_atlassian_debug.log", 

20 filemode="a", 

21) 

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

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

24 

25 

26def get_available_services(): 

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

28 confluence_vars = all( 

29 [ 

30 os.getenv("CONFLUENCE_URL"), 

31 os.getenv("CONFLUENCE_USERNAME"), 

32 os.getenv("CONFLUENCE_API_TOKEN"), 

33 ] 

34 ) 

35 

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

37 

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

39 

40 

41# Initialize services based on available credentials 

42services = get_available_services() 

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

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

45app = Server("mcp-atlassian") 

46 

47 

48@app.list_resources() 

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

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

51 resources = [] 

52 

53 # Add Confluence spaces the user has contributed to 

54 if confluence_fetcher: 

55 try: 

56 # Get spaces the user has contributed to 

57 spaces = confluence_fetcher.get_user_contributed_spaces(limit=250) 

58 

59 # Add spaces to resources 

60 resources.extend( 

61 [ 

62 Resource( 

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

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

65 mimeType="text/plain", 

66 description=( 

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

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

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

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

71 ).strip(), 

72 ) 

73 for space in spaces.values() 

74 ] 

75 ) 

76 except Exception as e: 

77 logger.error(f"Error fetching Confluence spaces: {str(e)}") 

78 

79 # Add Jira projects the user is involved with 

80 if jira_fetcher: 

81 try: 

82 # Get current user's account ID 

83 account_id = jira_fetcher.get_current_user_account_id() 

84 

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

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

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

88 

89 # Extract and deduplicate projects 

90 projects = {} 

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

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

93 project_key = project.get("key") 

94 if project_key and project_key not in projects: 

95 projects[project_key] = { 

96 "key": project_key, 

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

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

99 } 

100 

101 # Add projects to resources 

102 resources.extend( 

103 [ 

104 Resource( 

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

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

107 mimeType="text/plain", 

108 description=( 

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

110 ).strip(), 

111 ) 

112 for project in projects.values() 

113 ] 

114 ) 

115 except Exception as e: 

116 logger.error(f"Error fetching Jira projects: {str(e)}") 

117 

118 return resources 

119 

120 

121@app.read_resource() 

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

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

124 uri_str = str(uri) 

125 

126 # Handle Confluence resources 

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

128 if not services["confluence"]: 

129 raise ValueError("Confluence is not configured. Please provide Confluence credentials.") 

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

131 

132 # Handle space listing 

133 if len(parts) == 1: 

134 space_key = parts[0] 

135 

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

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

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

139 

140 if not documents: 

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

142 documents = confluence_fetcher.get_space_pages(space_key, limit=10) 

143 

144 content = [] 

145 for doc in documents: 

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

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

148 

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

150 

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

152 

153 # Handle specific page 

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

155 space_key = parts[0] 

156 title = parts[2] 

157 doc = confluence_fetcher.get_page_by_title(space_key, title) 

158 

159 if not doc: 

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

161 

162 return doc.page_content 

163 

164 # Handle Jira resources 

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

166 if not services["jira"]: 

167 raise ValueError("Jira is not configured. Please provide Jira credentials.") 

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

169 

170 # Handle project listing 

171 if len(parts) == 1: 

172 project_key = parts[0] 

173 

174 # Get current user's account ID 

175 account_id = jira_fetcher.get_current_user_account_id() 

176 

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

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

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

180 

181 if not issues: 

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

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

184 

185 content = [] 

186 for issue in issues: 

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

188 title = issue.metadata.get("title", "Untitled") 

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

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

191 

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

193 

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

195 

196 # Handle specific issue 

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

198 issue_key = parts[2] 

199 issue = jira_fetcher.get_issue(issue_key) 

200 return issue.page_content 

201 

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

203 

204 

205@app.list_tools() 

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

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

208 tools = [] 

209 

210 if confluence_fetcher: 

211 tools.extend( 

212 [ 

213 Tool( 

214 name="confluence_search", 

215 description="Search Confluence content using CQL", 

216 inputSchema={ 

217 "type": "object", 

218 "properties": { 

219 "query": { 

220 "type": "string", 

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

222 }, 

223 "limit": { 

224 "type": "number", 

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

226 "default": 10, 

227 "minimum": 1, 

228 "maximum": 50, 

229 }, 

230 }, 

231 "required": ["query"], 

232 }, 

233 ), 

234 Tool( 

235 name="confluence_get_page", 

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

237 inputSchema={ 

238 "type": "object", 

239 "properties": { 

240 "page_id": { 

241 "type": "string", 

242 "description": "Confluence page ID (numeric ID, can be parsed from URL, e.g. from 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title' -> '123456789')", 

243 }, 

244 "include_metadata": { 

245 "type": "boolean", 

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

247 "default": True, 

248 }, 

249 }, 

250 "required": ["page_id"], 

251 }, 

252 ), 

253 Tool( 

254 name="confluence_get_comments", 

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

256 inputSchema={ 

257 "type": "object", 

258 "properties": { 

259 "page_id": { 

260 "type": "string", 

261 "description": "Confluence page ID (numeric ID, can be parsed from URL, e.g. from 'https://example.atlassian.net/wiki/spaces/TEAM/pages/123456789/Page+Title' -> '123456789')", 

262 } 

263 }, 

264 "required": ["page_id"], 

265 }, 

266 ), 

267 Tool( 

268 name="confluence_create_page", 

269 description="Create a new Confluence page", 

270 inputSchema={ 

271 "type": "object", 

272 "properties": { 

273 "space_key": { 

274 "type": "string", 

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

276 }, 

277 "title": { 

278 "type": "string", 

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

280 }, 

281 "content": { 

282 "type": "string", 

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

284 }, 

285 "parent_id": { 

286 "type": "string", 

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

288 }, 

289 }, 

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

291 }, 

292 ), 

293 Tool( 

294 name="confluence_update_page", 

295 description="Update an existing Confluence page", 

296 inputSchema={ 

297 "type": "object", 

298 "properties": { 

299 "page_id": { 

300 "type": "string", 

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

302 }, 

303 "title": { 

304 "type": "string", 

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

306 }, 

307 "content": { 

308 "type": "string", 

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

310 }, 

311 "minor_edit": { 

312 "type": "boolean", 

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

314 "default": False, 

315 }, 

316 "version_comment": { 

317 "type": "string", 

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

319 "default": "", 

320 }, 

321 }, 

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

323 }, 

324 ), 

325 ] 

326 ) 

327 

328 if jira_fetcher: 

329 tools.extend( 

330 [ 

331 Tool( 

332 name="jira_get_issue", 

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

334 inputSchema={ 

335 "type": "object", 

336 "properties": { 

337 "issue_key": { 

338 "type": "string", 

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

340 }, 

341 "expand": { 

342 "type": "string", 

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

344 "default": None, 

345 }, 

346 "comment_limit": { 

347 "type": "integer", 

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

349 "minimum": 0, 

350 "maximum": 100, 

351 "default": None, 

352 }, 

353 }, 

354 "required": ["issue_key"], 

355 }, 

356 ), 

357 Tool( 

358 name="jira_search", 

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

360 inputSchema={ 

361 "type": "object", 

362 "properties": { 

363 "jql": { 

364 "type": "string", 

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

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

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

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

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

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

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

372 }, 

373 "fields": { 

374 "type": "string", 

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

376 "default": "*all", 

377 }, 

378 "limit": { 

379 "type": "number", 

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

381 "default": 10, 

382 "minimum": 1, 

383 "maximum": 50, 

384 }, 

385 }, 

386 "required": ["jql"], 

387 }, 

388 ), 

389 Tool( 

390 name="jira_get_project_issues", 

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

392 inputSchema={ 

393 "type": "object", 

394 "properties": { 

395 "project_key": { 

396 "type": "string", 

397 "description": "The project key", 

398 }, 

399 "limit": { 

400 "type": "number", 

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

402 "default": 10, 

403 "minimum": 1, 

404 "maximum": 50, 

405 }, 

406 }, 

407 "required": ["project_key"], 

408 }, 

409 ), 

410 Tool( 

411 name="jira_create_issue", 

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

413 inputSchema={ 

414 "type": "object", 

415 "properties": { 

416 "project_key": { 

417 "type": "string", 

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

419 }, 

420 "summary": { 

421 "type": "string", 

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

423 }, 

424 "issue_type": { 

425 "type": "string", 

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

427 }, 

428 "assignee": { 

429 "type": "string", 

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

431 }, 

432 "description": { 

433 "type": "string", 

434 "description": "Issue description", 

435 "default": "", 

436 }, 

437 "additional_fields": { 

438 "type": "string", 

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

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

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

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

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

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

445 "default": "{}", 

446 }, 

447 }, 

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

449 }, 

450 ), 

451 Tool( 

452 name="jira_update_issue", 

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

454 inputSchema={ 

455 "type": "object", 

456 "properties": { 

457 "issue_key": { 

458 "type": "string", 

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

460 }, 

461 "fields": { 

462 "type": "string", 

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

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

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

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

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

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

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

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

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

472 }, 

473 "additional_fields": { 

474 "type": "string", 

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

476 "default": "{}", 

477 }, 

478 }, 

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

480 }, 

481 ), 

482 Tool( 

483 name="jira_delete_issue", 

484 description="Delete an existing Jira issue", 

485 inputSchema={ 

486 "type": "object", 

487 "properties": { 

488 "issue_key": { 

489 "type": "string", 

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

491 }, 

492 }, 

493 "required": ["issue_key"], 

494 }, 

495 ), 

496 Tool( 

497 name="jira_add_comment", 

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

499 inputSchema={ 

500 "type": "object", 

501 "properties": { 

502 "issue_key": { 

503 "type": "string", 

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

505 }, 

506 "comment": { 

507 "type": "string", 

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

509 }, 

510 }, 

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

512 }, 

513 ), 

514 Tool( 

515 name="jira_link_to_epic", 

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

517 inputSchema={ 

518 "type": "object", 

519 "properties": { 

520 "issue_key": { 

521 "type": "string", 

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

523 }, 

524 "epic_key": { 

525 "type": "string", 

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

527 }, 

528 }, 

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

530 }, 

531 ), 

532 Tool( 

533 name="jira_get_epic_issues", 

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

535 inputSchema={ 

536 "type": "object", 

537 "properties": { 

538 "epic_key": { 

539 "type": "string", 

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

541 }, 

542 "limit": { 

543 "type": "number", 

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

545 "default": 10, 

546 "minimum": 1, 

547 "maximum": 50, 

548 }, 

549 }, 

550 "required": ["epic_key"], 

551 }, 

552 ), 

553 Tool( 

554 name="jira_get_transitions", 

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

556 inputSchema={ 

557 "type": "object", 

558 "properties": { 

559 "issue_key": { 

560 "type": "string", 

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

562 }, 

563 }, 

564 "required": ["issue_key"], 

565 }, 

566 ), 

567 Tool( 

568 name="jira_transition_issue", 

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

570 inputSchema={ 

571 "type": "object", 

572 "properties": { 

573 "issue_key": { 

574 "type": "string", 

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

576 }, 

577 "transition_id": { 

578 "type": "string", 

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

580 }, 

581 "fields": { 

582 "type": "string", 

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

584 "default": "{}", 

585 }, 

586 "comment": { 

587 "type": "string", 

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

589 }, 

590 }, 

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

592 }, 

593 ), 

594 ] 

595 ) 

596 

597 return tools 

598 

599 

600@app.call_tool() 

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

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

603 try: 

604 # Helper functions for formatting results 

605 def format_comment(comment): 

606 return { 

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

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

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

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

611 } 

612 

613 def format_issue(doc): 

614 return { 

615 "key": doc.metadata["key"], 

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

617 "type": doc.metadata["type"], 

618 "status": doc.metadata["status"], 

619 "created_date": doc.metadata["created_date"], 

620 "priority": doc.metadata["priority"], 

621 "link": doc.metadata["link"], 

622 } 

623 

624 def format_transition(transition): 

625 return { 

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

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

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

629 } 

630 

631 # Confluence operations 

632 if name == "confluence_search": 

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

634 documents = confluence_fetcher.search(arguments["query"], limit) 

635 search_results = [ 

636 { 

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

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

639 "space": doc.metadata["space"], 

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

641 "last_modified": doc.metadata["last_modified"], 

642 "type": doc.metadata["type"], 

643 "excerpt": doc.page_content, 

644 } 

645 for doc in documents 

646 ] 

647 

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

649 

650 elif name == "confluence_get_page": 

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

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

653 

654 if include_metadata: 

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

656 else: 

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

658 

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

660 

661 elif name == "confluence_get_comments": 

662 comments = confluence_fetcher.get_page_comments(arguments["page_id"]) 

663 formatted_comments = [format_comment(comment) for comment in comments] 

664 

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

666 

667 elif name == "confluence_create_page": 

668 # Convert markdown content to HTML storage format 

669 space_key = arguments["space_key"] 

670 title = arguments["title"] 

671 content = arguments["content"] 

672 parent_id = arguments.get("parent_id") 

673 

674 # Convert markdown to Confluence storage format 

675 storage_format = markdown_to_confluence_storage(content) 

676 

677 # Create the page 

678 doc = confluence_fetcher.create_page( 

679 space_key=space_key, 

680 title=title, 

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

682 parent_id=parent_id, 

683 ) 

684 

685 result = { 

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

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

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

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

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

691 "content": doc.page_content[:500] + "..." if len(doc.page_content) > 500 else doc.page_content, 

692 } 

693 

694 return [ 

695 TextContent( 

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

697 ) 

698 ] 

699 

700 elif name == "confluence_update_page": 

701 page_id = arguments["page_id"] 

702 title = arguments["title"] 

703 content = arguments["content"] 

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

705 

706 # Convert markdown to Confluence storage format 

707 storage_format = markdown_to_confluence_storage(content) 

708 

709 # Update the page 

710 doc = confluence_fetcher.update_page( 

711 page_id=page_id, 

712 title=title, 

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

714 minor_edit=minor_edit, 

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

716 ) 

717 

718 result = { 

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

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

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

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

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

724 "content": doc.page_content[:500] + "..." if len(doc.page_content) > 500 else doc.page_content, 

725 } 

726 

727 return [ 

728 TextContent( 

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

730 ) 

731 ] 

732 

733 # Jira operations 

734 elif name == "jira_get_issue": 

735 doc = jira_fetcher.get_issue( 

736 arguments["issue_key"], expand=arguments.get("expand"), comment_limit=arguments.get("comment_limit") 

737 ) 

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

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

740 

741 elif name == "jira_search": 

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

743 documents = jira_fetcher.search_issues( 

744 arguments["jql"], fields=arguments.get("fields", "*all"), limit=limit 

745 ) 

746 search_results = [format_issue(doc) for doc in documents] 

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

748 

749 elif name == "jira_get_project_issues": 

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

751 documents = jira_fetcher.get_project_issues(arguments["project_key"], limit=limit) 

752 project_issues = [format_issue(doc) for doc in documents] 

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

754 

755 elif name == "jira_create_issue": 

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

757 

758 # If assignee is in additional_fields, move it to the main arguments 

759 if "assignee" in additional_fields: 

760 if not arguments.get("assignee"): # Only if not already specified in main arguments 

761 assignee_data = additional_fields.pop("assignee") 

762 if isinstance(assignee_data, dict): 

763 arguments["assignee"] = assignee_data.get("id") or assignee_data.get("accountId") 

764 else: 

765 arguments["assignee"] = str(assignee_data) 

766 

767 doc = jira_fetcher.create_issue( 

768 project_key=arguments["project_key"], 

769 summary=arguments["summary"], 

770 issue_type=arguments["issue_type"], 

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

772 assignee=arguments.get("assignee"), 

773 **additional_fields, 

774 ) 

775 result = json.dumps({"content": doc.page_content, "metadata": doc.metadata}, indent=2, ensure_ascii=False) 

776 return [TextContent(type="text", text=f"Issue created successfully:\n{result}")] 

777 

778 elif name == "jira_update_issue": 

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

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

781 

782 doc = jira_fetcher.update_issue(issue_key=arguments["issue_key"], fields=fields, **additional_fields) 

783 result = json.dumps({"content": doc.page_content, "metadata": doc.metadata}, indent=2, ensure_ascii=False) 

784 return [TextContent(type="text", text=f"Issue updated successfully:\n{result}")] 

785 

786 elif name == "jira_delete_issue": 

787 issue_key = arguments["issue_key"] 

788 deleted = jira_fetcher.delete_issue(issue_key) 

789 result = {"message": f"Issue {issue_key} has been deleted successfully."} 

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

791 

792 elif name == "jira_add_comment": 

793 comment = jira_fetcher.add_comment(arguments["issue_key"], arguments["comment"]) 

794 result = {"message": "Comment added successfully", "comment": comment} 

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

796 

797 elif name == "jira_link_to_epic": 

798 issue_key = arguments["issue_key"] 

799 epic_key = arguments["epic_key"] 

800 linked_issue = jira_fetcher.link_issue_to_epic(issue_key, epic_key) 

801 result = { 

802 "message": f"Issue {issue_key} has been linked to epic {epic_key}.", 

803 "issue": { 

804 "key": linked_issue.metadata["key"], 

805 "title": linked_issue.metadata["title"], 

806 "type": linked_issue.metadata["type"], 

807 "status": linked_issue.metadata["status"], 

808 "link": linked_issue.metadata["link"], 

809 }, 

810 } 

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

812 

813 elif name == "jira_get_epic_issues": 

814 epic_key = arguments["epic_key"] 

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

816 documents = jira_fetcher.get_epic_issues(epic_key, limit=limit) 

817 epic_issues = [format_issue(doc) for doc in documents] 

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

819 

820 elif name == "jira_get_transitions": 

821 issue_key = arguments["issue_key"] 

822 transitions = jira_fetcher.get_available_transitions(issue_key) 

823 transitions_result = [format_transition(transition) for transition in transitions] 

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

825 

826 elif name == "jira_transition_issue": 

827 import base64 

828 

829 import httpx 

830 

831 issue_key = arguments["issue_key"] 

832 transition_id = arguments["transition_id"] 

833 

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

835 if not isinstance(transition_id, str): 

836 transition_id = str(transition_id) 

837 

838 # Get Jira API credentials from environment/config 

839 jira_url = jira_fetcher.config.url.rstrip("/") 

840 username = jira_fetcher.config.username 

841 api_token = jira_fetcher.config.api_token 

842 

843 # Construct minimal transition payload 

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

845 

846 # Add fields if provided 

847 if "fields" in arguments: 

848 try: 

849 fields = json.loads(arguments.get("fields", "{}")) 

850 if fields and isinstance(fields, dict): 

851 payload["fields"] = fields 

852 except Exception as e: 

853 return [ 

854 TextContent( 

855 type="text", 

856 text=json.dumps( 

857 {"error": f"Invalid fields format: {str(e)}", "status": "error"}, 

858 indent=2, 

859 ensure_ascii=False, 

860 ), 

861 ) 

862 ] 

863 

864 # Add comment if provided 

865 if "comment" in arguments and arguments["comment"]: 

866 comment = arguments["comment"] 

867 if not isinstance(comment, str): 

868 comment = str(comment) 

869 

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

871 

872 # Create auth header 

873 auth_str = f"{username}:{api_token}" 

874 auth_bytes = auth_str.encode("ascii") 

875 auth_b64 = base64.b64encode(auth_bytes).decode("ascii") 

876 

877 # Prepare headers 

878 headers = { 

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

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

881 "Accept": "application/json", 

882 } 

883 

884 # Log entire request for debugging 

885 logger.info(f"Sending transition request to {jira_url}/rest/api/2/issue/{issue_key}/transitions") 

886 logger.info(f"Headers: {headers}") 

887 logger.info(f"Payload: {payload}") 

888 

889 try: 

890 # Make direct HTTP request 

891 transition_url = f"{jira_url}/rest/api/2/issue/{issue_key}/transitions" 

892 response = httpx.post(transition_url, json=payload, headers=headers, timeout=30.0) 

893 

894 # Check response 

895 if response.status_code >= 400: 

896 return [ 

897 TextContent( 

898 type="text", 

899 text=json.dumps( 

900 { 

901 "error": f"Jira API error: {response.status_code} - {response.text}", 

902 "status": "error", 

903 }, 

904 indent=2, 

905 ensure_ascii=False, 

906 ), 

907 ) 

908 ] 

909 

910 # Now fetch the updated issue - also using direct HTTP 

911 issue_url = f"{jira_url}/rest/api/2/issue/{issue_key}" 

912 issue_response = httpx.get(issue_url, headers=headers, timeout=30.0) 

913 

914 if issue_response.status_code >= 400: 

915 return [ 

916 TextContent( 

917 type="text", 

918 text=json.dumps( 

919 { 

920 "error": f"Failed to fetch updated issue: {issue_response.status_code} - {issue_response.text}", 

921 "status": "error", 

922 }, 

923 indent=2, 

924 ensure_ascii=False, 

925 ), 

926 ) 

927 ] 

928 

929 # Parse and return issue data 

930 issue_data = issue_response.json() 

931 

932 # Extract essential issue information 

933 status = issue_data["fields"]["status"]["name"] 

934 summary = issue_data["fields"].get("summary", "") 

935 issue_type = issue_data["fields"]["issuetype"]["name"] 

936 

937 # Clean and process description text if available 

938 description = "" 

939 if issue_data["fields"].get("description"): 

940 description = jira_fetcher.preprocessor.clean_jira_text(issue_data["fields"]["description"]) 

941 

942 result = { 

943 "message": f"Successfully transitioned issue {issue_key} to {status}", 

944 "issue": { 

945 "key": issue_key, 

946 "title": summary, 

947 "type": issue_type, 

948 "status": status, 

949 "description": description, 

950 }, 

951 } 

952 

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

954 

955 except Exception as e: 

956 error_message = str(e) 

957 logger.error(f"Exception in direct transition API call: {error_message}") 

958 return [ 

959 TextContent( 

960 type="text", 

961 text=json.dumps( 

962 { 

963 "error": f"Network or API error: {error_message}", 

964 "status": "error", 

965 "details": f"Full error: {repr(e)}", 

966 }, 

967 indent=2, 

968 ensure_ascii=False, 

969 ), 

970 ) 

971 ] 

972 

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

974 

975 except Exception as e: 

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

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

978 

979 

980async def main(): 

981 # Import here to avoid issues with event loops 

982 from mcp.server.stdio import stdio_server 

983 

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

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

986 

987 

988if __name__ == "__main__": 

989 import asyncio 

990 

991 asyncio.run(main())