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

141 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-22 16:34 +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 

13 

14# Configure logging 

15logging.basicConfig(level=logging.WARNING) 

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

17logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.WARNING) 

18 

19 

20def get_available_services(): 

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

22 confluence_vars = all( 

23 [ 

24 os.getenv("CONFLUENCE_URL"), 

25 os.getenv("CONFLUENCE_USERNAME"), 

26 os.getenv("CONFLUENCE_API_TOKEN"), 

27 ] 

28 ) 

29 

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

31 

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

33 

34 

35# Initialize services based on available credentials 

36services = get_available_services() 

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

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

39app = Server("mcp-atlassian") 

40 

41 

42@app.list_resources() 

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

44 """List available Confluence spaces and Jira projects as resources.""" 

45 resources = [] 

46 

47 # Add Confluence spaces 

48 if confluence_fetcher: 

49 spaces_response = confluence_fetcher.get_spaces() 

50 if isinstance(spaces_response, dict) and "results" in spaces_response: 

51 spaces = spaces_response["results"] 

52 resources.extend( 

53 [ 

54 Resource( 

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

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

57 mimeType="text/plain", 

58 description=space.get("description", {}).get("plain", {}).get("value", ""), 

59 ) 

60 for space in spaces 

61 ] 

62 ) 

63 

64 # Add Jira projects 

65 if jira_fetcher: 

66 try: 

67 projects = jira_fetcher.jira.projects() 

68 resources.extend( 

69 [ 

70 Resource( 

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

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

73 mimeType="text/plain", 

74 description=project.get("description", ""), 

75 ) 

76 for project in projects 

77 ] 

78 ) 

79 except Exception as e: 

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

81 

82 return resources 

83 

84 

85@app.read_resource() 

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

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

88 uri_str = str(uri) 

89 

90 # Handle Confluence resources 

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

92 if not services["confluence"]: 

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

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

95 

96 # Handle space listing 

97 if len(parts) == 1: 

98 space_key = parts[0] 

99 documents = confluence_fetcher.get_space_pages(space_key) 

100 content = [] 

101 for doc in documents: 

102 content.append(f"# {doc.metadata['title']}\n\n{doc.page_content}\n---") 

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

104 

105 # Handle specific page 

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

107 space_key = parts[0] 

108 title = parts[2] 

109 doc = confluence_fetcher.get_page_by_title(space_key, title) 

110 

111 if not doc: 

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

113 

114 return doc.page_content 

115 

116 # Handle Jira resources 

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

118 if not services["jira"]: 

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

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

121 

122 # Handle project listing 

123 if len(parts) == 1: 

124 project_key = parts[0] 

125 issues = jira_fetcher.get_project_issues(project_key) 

126 content = [] 

127 for issue in issues: 

128 content.append(f"# {issue.metadata['key']}: {issue.metadata['title']}\n\n{issue.page_content}\n---") 

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

130 

131 # Handle specific issue 

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

133 issue_key = parts[2] 

134 issue = jira_fetcher.get_issue(issue_key) 

135 return issue.page_content 

136 

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

138 

139 

140@app.list_tools() 

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

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

143 tools = [] 

144 

145 if confluence_fetcher: 

146 tools.extend( 

147 [ 

148 Tool( 

149 name="confluence_search", 

150 description="Search Confluence content using CQL", 

151 inputSchema={ 

152 "type": "object", 

153 "properties": { 

154 "query": { 

155 "type": "string", 

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

157 }, 

158 "limit": { 

159 "type": "number", 

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

161 "default": 10, 

162 "minimum": 1, 

163 "maximum": 50, 

164 }, 

165 }, 

166 "required": ["query"], 

167 }, 

168 ), 

169 Tool( 

170 name="confluence_get_page", 

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

172 inputSchema={ 

173 "type": "object", 

174 "properties": { 

175 "page_id": { 

176 "type": "string", 

177 "description": "Confluence page ID", 

178 }, 

179 "include_metadata": { 

180 "type": "boolean", 

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

182 "default": True, 

183 }, 

184 }, 

185 "required": ["page_id"], 

186 }, 

187 ), 

188 Tool( 

189 name="confluence_get_comments", 

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

191 inputSchema={ 

192 "type": "object", 

193 "properties": { 

194 "page_id": { 

195 "type": "string", 

196 "description": "Confluence page ID", 

197 } 

198 }, 

199 "required": ["page_id"], 

200 }, 

201 ), 

202 ] 

203 ) 

204 

205 if jira_fetcher: 

206 tools.extend( 

207 [ 

208 Tool( 

209 name="jira_get_issue", 

210 description="Get details of a specific Jira issue", 

211 inputSchema={ 

212 "type": "object", 

213 "properties": { 

214 "issue_key": { 

215 "type": "string", 

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

217 }, 

218 "expand": { 

219 "type": "string", 

220 "description": "Optional fields to expand", 

221 "default": None, 

222 }, 

223 }, 

224 "required": ["issue_key"], 

225 }, 

226 ), 

227 Tool( 

228 name="jira_search", 

229 description="Search Jira issues using JQL", 

230 inputSchema={ 

231 "type": "object", 

232 "properties": { 

233 "jql": { 

234 "type": "string", 

235 "description": "JQL query string", 

236 }, 

237 "fields": { 

238 "type": "string", 

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

240 "default": "*all", 

241 }, 

242 "limit": { 

243 "type": "number", 

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

245 "default": 10, 

246 "minimum": 1, 

247 "maximum": 50, 

248 }, 

249 }, 

250 "required": ["jql"], 

251 }, 

252 ), 

253 Tool( 

254 name="jira_get_project_issues", 

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

256 inputSchema={ 

257 "type": "object", 

258 "properties": { 

259 "project_key": { 

260 "type": "string", 

261 "description": "The project key", 

262 }, 

263 "limit": { 

264 "type": "number", 

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

266 "default": 10, 

267 "minimum": 1, 

268 "maximum": 50, 

269 }, 

270 }, 

271 "required": ["project_key"], 

272 }, 

273 ), 

274 Tool( 

275 name="jira_create_issue", 

276 description="Create a new Jira issue", 

277 inputSchema={ 

278 "type": "object", 

279 "properties": { 

280 "project_key": { 

281 "type": "string", 

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

283 }, 

284 "summary": { 

285 "type": "string", 

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

287 }, 

288 "issue_type": { 

289 "type": "string", 

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

291 }, 

292 "description": { 

293 "type": "string", 

294 "description": "Issue description", 

295 "default": "", 

296 }, 

297 "additional_fields": { 

298 "type": "string", 

299 "description": "Optional JSON string of additional fields to set", 

300 "default": "{}", 

301 }, 

302 }, 

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

304 }, 

305 ), 

306 Tool( 

307 name="jira_update_issue", 

308 description="Update an existing Jira issue", 

309 inputSchema={ 

310 "type": "object", 

311 "properties": { 

312 "issue_key": { 

313 "type": "string", 

314 "description": "Jira issue key", 

315 }, 

316 "fields": { 

317 "type": "string", 

318 "description": "A valid JSON object of fields to update", 

319 }, 

320 "additional_fields": { 

321 "type": "string", 

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

323 "default": "{}", 

324 }, 

325 }, 

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

327 }, 

328 ), 

329 Tool( 

330 name="jira_delete_issue", 

331 description="Delete an existing Jira issue", 

332 inputSchema={ 

333 "type": "object", 

334 "properties": { 

335 "issue_key": { 

336 "type": "string", 

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

338 }, 

339 }, 

340 "required": ["issue_key"], 

341 }, 

342 ), 

343 ] 

344 ) 

345 

346 return tools 

347 

348 

349@app.call_tool() 

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

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

352 try: 

353 if name == "confluence_search": 

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

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

356 search_results = [ 

357 { 

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

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

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

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

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

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

364 "excerpt": doc.page_content, 

365 } 

366 for doc in documents 

367 ] 

368 

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

370 

371 elif name == "confluence_get_page": 

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

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

374 

375 if include_metadata: 

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

377 else: 

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

379 

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

381 

382 elif name == "confluence_get_comments": 

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

384 formatted_comments = [ 

385 { 

386 "author": comment.metadata["author_name"], 

387 "created": comment.metadata["last_modified"], 

388 "content": comment.page_content, 

389 } 

390 for comment in comments 

391 ] 

392 

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

394 

395 elif name == "jira_get_issue": 

396 doc = jira_fetcher.get_issue(arguments["issue_key"], expand=arguments.get("expand")) 

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

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

399 

400 elif name == "jira_search": 

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

402 documents = jira_fetcher.search_issues( 

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

404 ) 

405 search_results = [ 

406 { 

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

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

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

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

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

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

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

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

415 } 

416 for doc in documents 

417 ] 

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

419 

420 elif name == "jira_get_project_issues": 

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

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

423 project_issues = [ 

424 { 

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

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

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

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

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

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

431 } 

432 for doc in documents 

433 ] 

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

435 

436 elif name == "jira_create_issue": 

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

438 doc = jira_fetcher.create_issue( 

439 project_key=arguments["project_key"], 

440 summary=arguments["summary"], 

441 issue_type=arguments["issue_type"], 

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

443 **additional_fields, 

444 ) 

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

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

447 

448 elif name == "jira_update_issue": 

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

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

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

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

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

454 

455 elif name == "jira_delete_issue": 

456 issue_key = arguments["issue_key"] 

457 deleted = jira_fetcher.delete_issue(issue_key) 

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

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

460 

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

462 

463 except Exception as e: 

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

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

466 

467 

468async def main(): 

469 # Import here to avoid issues with event loops 

470 from mcp.server.stdio import stdio_server 

471 

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

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

474 

475 

476if __name__ == "__main__": 

477 import asyncio 

478 

479 asyncio.run(main())