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

1 import json

2 import logging

3 import os

4 from collections.abc import Sequence

5 from typing import Any

6

7 from mcp.server import Server

8 from mcp.types import Resource, TextContent, Tool

9 from pydantic import AnyUrl

10

11 from .confluence import ConfluenceFetcher

12 from .jira import JiraFetcher

13

14# Configure logging

15 logging.basicConfig(level=logging.WARNING)

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

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

18

19

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

36 services = get_available_services()

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

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

39 app = Server("mcp-atlassian")

40

41

42@app.list_resources()

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

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

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

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

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

476 if __name__ == "__main__":

477 import asyncio

478

479 asyncio.run(main())