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
« 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
7from mcp.server import Server
8from mcp.types import Resource, TextContent, Tool
9from pydantic import AnyUrl
11from .confluence import ConfluenceFetcher
12from .jira import JiraFetcher
14# Configure logging
15logging.basicConfig(level=logging.WARNING)
16logger = logging.getLogger("mcp-atlassian")
17logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.WARNING)
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 )
30 jira_vars = all([os.getenv("JIRA_URL"), os.getenv("JIRA_USERNAME"), os.getenv("JIRA_API_TOKEN")])
32 return {"confluence": confluence_vars, "jira": jira_vars}
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")
42@app.list_resources()
43async def list_resources() -> list[Resource]:
44 """List available Confluence spaces and Jira projects as resources."""
45 resources = []
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 )
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)}")
82 return resources
85@app.read_resource()
86async def read_resource(uri: AnyUrl) -> str:
87 """Read content from Confluence or Jira."""
88 uri_str = str(uri)
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("/")
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)
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)
111 if not doc:
112 raise ValueError(f"Page not found: {title}")
114 return doc.page_content
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("/")
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)
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
137 raise ValueError(f"Invalid resource URI: {uri}")
140@app.list_tools()
141async def list_tools() -> list[Tool]:
142 """List available Confluence and Jira tools."""
143 tools = []
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 )
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 )
346 return tools
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 ]
369 return [TextContent(type="text", text=json.dumps(search_results, indent=2))]
371 elif name == "confluence_get_page":
372 doc = confluence_fetcher.get_page_content(arguments["page_id"])
373 include_metadata = arguments.get("include_metadata", True)
375 if include_metadata:
376 result = {"content": doc.page_content, "metadata": doc.metadata}
377 else:
378 result = {"content": doc.page_content}
380 return [TextContent(type="text", text=json.dumps(result, indent=2))]
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 ]
393 return [TextContent(type="text", text=json.dumps(formatted_comments, indent=2))]
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))]
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))]
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))]
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}")]
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}")]
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))]
461 raise ValueError(f"Unknown tool: {name}")
463 except Exception as e:
464 logger.error(f"Tool execution error: {str(e)}")
465 raise RuntimeError(f"Tool execution failed: {str(e)}")
468async def main():
469 # Import here to avoid issues with event loops
470 from mcp.server.stdio import stdio_server
472 async with stdio_server() as (read_stream, write_stream):
473 await app.run(read_stream, write_stream, app.create_initialization_options())
476if __name__ == "__main__":
477 import asyncio
479 asyncio.run(main())