Home Assistant MCP
by allenporter
- src
- mcp_atlassian
import json
import logging
import os
from collections.abc import Sequence
from typing import Any
from mcp.server import Server
from mcp.types import Resource, TextContent, Tool
from pydantic import AnyUrl
from .confluence import ConfluenceFetcher
from .jira import JiraFetcher
# Configure logging
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger("mcp-atlassian")
logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.WARNING)
def get_available_services():
"""Determine which services are available based on environment variables."""
confluence_vars = all(
[
os.getenv("CONFLUENCE_URL"),
os.getenv("CONFLUENCE_USERNAME"),
os.getenv("CONFLUENCE_API_TOKEN"),
]
)
jira_vars = all([os.getenv("JIRA_URL"), os.getenv("JIRA_USERNAME"), os.getenv("JIRA_API_TOKEN")])
return {"confluence": confluence_vars, "jira": jira_vars}
# Initialize services based on available credentials
services = get_available_services()
confluence_fetcher = ConfluenceFetcher() if services["confluence"] else None
jira_fetcher = JiraFetcher() if services["jira"] else None
app = Server("mcp-atlassian")
@app.list_resources()
async def list_resources() -> list[Resource]:
"""List available Confluence spaces and Jira projects as resources."""
resources = []
# Add Confluence spaces
if confluence_fetcher:
spaces_response = confluence_fetcher.get_spaces()
if isinstance(spaces_response, dict) and "results" in spaces_response:
spaces = spaces_response["results"]
resources.extend(
[
Resource(
uri=AnyUrl(f"confluence://{space['key']}"),
name=f"Confluence Space: {space['name']}",
mimeType="text/plain",
description=space.get("description", {}).get("plain", {}).get("value", ""),
)
for space in spaces
]
)
# Add Jira projects
if jira_fetcher:
try:
projects = jira_fetcher.jira.projects()
resources.extend(
[
Resource(
uri=AnyUrl(f"jira://{project['key']}"),
name=f"Jira Project: {project['name']}",
mimeType="text/plain",
description=project.get("description", ""),
)
for project in projects
]
)
except Exception as e:
logger.error(f"Error fetching Jira projects: {str(e)}")
return resources
@app.read_resource()
async def read_resource(uri: AnyUrl) -> str:
"""Read content from Confluence or Jira."""
uri_str = str(uri)
# Handle Confluence resources
if uri_str.startswith("confluence://"):
if not services["confluence"]:
raise ValueError("Confluence is not configured. Please provide Confluence credentials.")
parts = uri_str.replace("confluence://", "").split("/")
# Handle space listing
if len(parts) == 1:
space_key = parts[0]
documents = confluence_fetcher.get_space_pages(space_key)
content = []
for doc in documents:
content.append(f"# {doc.metadata['title']}\n\n{doc.page_content}\n---")
return "\n\n".join(content)
# Handle specific page
elif len(parts) >= 3 and parts[1] == "pages":
space_key = parts[0]
title = parts[2]
doc = confluence_fetcher.get_page_by_title(space_key, title)
if not doc:
raise ValueError(f"Page not found: {title}")
return doc.page_content
# Handle Jira resources
elif uri_str.startswith("jira://"):
if not services["jira"]:
raise ValueError("Jira is not configured. Please provide Jira credentials.")
parts = uri_str.replace("jira://", "").split("/")
# Handle project listing
if len(parts) == 1:
project_key = parts[0]
issues = jira_fetcher.get_project_issues(project_key)
content = []
for issue in issues:
content.append(f"# {issue.metadata['key']}: {issue.metadata['title']}\n\n{issue.page_content}\n---")
return "\n\n".join(content)
# Handle specific issue
elif len(parts) >= 3 and parts[1] == "issues":
issue_key = parts[2]
issue = jira_fetcher.get_issue(issue_key)
return issue.page_content
raise ValueError(f"Invalid resource URI: {uri}")
@app.list_tools()
async def list_tools() -> list[Tool]:
"""List available Confluence and Jira tools."""
tools = []
if confluence_fetcher:
tools.extend(
[
Tool(
name="confluence_search",
description="Search Confluence content using CQL",
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "CQL query string (e.g. 'type=page AND space=DEV')",
},
"limit": {
"type": "number",
"description": "Maximum number of results (1-50)",
"default": 10,
"minimum": 1,
"maximum": 50,
},
},
"required": ["query"],
},
),
Tool(
name="confluence_get_page",
description="Get content of a specific Confluence page by ID",
inputSchema={
"type": "object",
"properties": {
"page_id": {
"type": "string",
"description": "Confluence page ID",
},
"include_metadata": {
"type": "boolean",
"description": "Whether to include page metadata",
"default": True,
},
},
"required": ["page_id"],
},
),
Tool(
name="confluence_get_comments",
description="Get comments for a specific Confluence page",
inputSchema={
"type": "object",
"properties": {
"page_id": {
"type": "string",
"description": "Confluence page ID",
}
},
"required": ["page_id"],
},
),
]
)
if jira_fetcher:
tools.extend(
[
Tool(
name="jira_get_issue",
description="Get details of a specific Jira issue",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "Jira issue key (e.g., 'PROJ-123')",
},
"expand": {
"type": "string",
"description": "Optional fields to expand",
"default": None,
},
},
"required": ["issue_key"],
},
),
Tool(
name="jira_search",
description="Search Jira issues using JQL",
inputSchema={
"type": "object",
"properties": {
"jql": {
"type": "string",
"description": "JQL query string",
},
"fields": {
"type": "string",
"description": "Comma-separated fields to return",
"default": "*all",
},
"limit": {
"type": "number",
"description": "Maximum number of results (1-50)",
"default": 10,
"minimum": 1,
"maximum": 50,
},
},
"required": ["jql"],
},
),
Tool(
name="jira_get_project_issues",
description="Get all issues for a specific Jira project",
inputSchema={
"type": "object",
"properties": {
"project_key": {
"type": "string",
"description": "The project key",
},
"limit": {
"type": "number",
"description": "Maximum number of results (1-50)",
"default": 10,
"minimum": 1,
"maximum": 50,
},
},
"required": ["project_key"],
},
),
Tool(
name="jira_create_issue",
description="Create a new Jira issue",
inputSchema={
"type": "object",
"properties": {
"project_key": {
"type": "string",
"description": "The JIRA project key (e.g. 'PROJ'). Never assume what it might be, always ask the user.",
},
"summary": {
"type": "string",
"description": "Summary/title of the issue",
},
"issue_type": {
"type": "string",
"description": "Issue type (e.g. 'Task', 'Bug', 'Story')",
},
"description": {
"type": "string",
"description": "Issue description",
"default": "",
},
"additional_fields": {
"type": "string",
"description": "Optional JSON string of additional fields to set",
"default": "{}",
},
},
"required": ["project_key", "summary", "issue_type"],
},
),
Tool(
name="jira_update_issue",
description="Update an existing Jira issue",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "Jira issue key",
},
"fields": {
"type": "string",
"description": "A valid JSON object of fields to update",
},
"additional_fields": {
"type": "string",
"description": "Optional JSON string of additional fields to update",
"default": "{}",
},
},
"required": ["issue_key", "fields"],
},
),
Tool(
name="jira_delete_issue",
description="Delete an existing Jira issue",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "Jira issue key (e.g. PROJ-123)",
},
},
"required": ["issue_key"],
},
),
]
)
return tools
@app.call_tool()
async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
"""Handle tool calls for Confluence and Jira operations."""
try:
if name == "confluence_search":
limit = min(int(arguments.get("limit", 10)), 50)
documents = confluence_fetcher.search(arguments["query"], limit)
search_results = [
{
"page_id": doc.metadata["page_id"],
"title": doc.metadata["title"],
"space": doc.metadata["space"],
"url": doc.metadata["url"],
"last_modified": doc.metadata["last_modified"],
"type": doc.metadata["type"],
"excerpt": doc.page_content,
}
for doc in documents
]
return [TextContent(type="text", text=json.dumps(search_results, indent=2))]
elif name == "confluence_get_page":
doc = confluence_fetcher.get_page_content(arguments["page_id"])
include_metadata = arguments.get("include_metadata", True)
if include_metadata:
result = {"content": doc.page_content, "metadata": doc.metadata}
else:
result = {"content": doc.page_content}
return [TextContent(type="text", text=json.dumps(result, indent=2))]
elif name == "confluence_get_comments":
comments = confluence_fetcher.get_page_comments(arguments["page_id"])
formatted_comments = [
{
"author": comment.metadata["author_name"],
"created": comment.metadata["last_modified"],
"content": comment.page_content,
}
for comment in comments
]
return [TextContent(type="text", text=json.dumps(formatted_comments, indent=2))]
elif name == "jira_get_issue":
doc = jira_fetcher.get_issue(arguments["issue_key"], expand=arguments.get("expand"))
result = {"content": doc.page_content, "metadata": doc.metadata}
return [TextContent(type="text", text=json.dumps(result, indent=2))]
elif name == "jira_search":
limit = min(int(arguments.get("limit", 10)), 50)
documents = jira_fetcher.search_issues(
arguments["jql"], fields=arguments.get("fields", "*all"), limit=limit
)
search_results = [
{
"key": doc.metadata["key"],
"title": doc.metadata["title"],
"type": doc.metadata["type"],
"status": doc.metadata["status"],
"created_date": doc.metadata["created_date"],
"priority": doc.metadata["priority"],
"link": doc.metadata["link"],
"excerpt": doc.page_content[:500] + "..." if len(doc.page_content) > 500 else doc.page_content,
}
for doc in documents
]
return [TextContent(type="text", text=json.dumps(search_results, indent=2))]
elif name == "jira_get_project_issues":
limit = min(int(arguments.get("limit", 10)), 50)
documents = jira_fetcher.get_project_issues(arguments["project_key"], limit=limit)
project_issues = [
{
"key": doc.metadata["key"],
"title": doc.metadata["title"],
"type": doc.metadata["type"],
"status": doc.metadata["status"],
"created_date": doc.metadata["created_date"],
"link": doc.metadata["link"],
}
for doc in documents
]
return [TextContent(type="text", text=json.dumps(project_issues, indent=2))]
elif name == "jira_create_issue":
additional_fields = json.loads(arguments.get("additional_fields", "{}"))
doc = jira_fetcher.create_issue(
project_key=arguments["project_key"],
summary=arguments["summary"],
issue_type=arguments["issue_type"],
description=arguments.get("description", ""),
**additional_fields,
)
result = json.dumps({"content": doc.page_content, "metadata": doc.metadata}, indent=2)
return [TextContent(type="text", text=f"Issue created successfully:\n{result}")]
elif name == "jira_update_issue":
fields = json.loads(arguments["fields"])
additional_fields = json.loads(arguments.get("additional_fields", "{}"))
doc = jira_fetcher.update_issue(issue_key=arguments["issue_key"], fields=fields, **additional_fields)
result = json.dumps({"content": doc.page_content, "metadata": doc.metadata}, indent=2)
return [TextContent(type="text", text=f"Issue updated successfully:\n{result}")]
elif name == "jira_delete_issue":
issue_key = arguments["issue_key"]
deleted = jira_fetcher.delete_issue(issue_key)
result = {"message": f"Issue {issue_key} has been deleted successfully."}
return [TextContent(type="text", text=json.dumps(result, indent=2))]
raise ValueError(f"Unknown tool: {name}")
except Exception as e:
logger.error(f"Tool execution error: {str(e)}")
raise RuntimeError(f"Tool execution failed: {str(e)}")
async def main():
# Import here to avoid issues with event loops
from mcp.server.stdio import stdio_server
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())