Clawith/backend/app/api/atlassian.py

303 lines
11 KiB
Python

"""Atlassian Rovo MCP Channel API routes.
Provides per-agent Atlassian integration configuration.
Unlike Slack/Discord (messaging channels), Atlassian is a tool-access channel:
the agent uses Jira, Confluence, and Compass via the Atlassian Rovo MCP server.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.permissions import check_agent_access, is_agent_creator
from app.core.security import get_current_user
from app.database import get_db
from app.models.channel_config import ChannelConfig
from app.models.user import User
router = APIRouter(tags=["atlassian"])
ATLASSIAN_MCP_URL = "https://mcp.atlassian.com/v1/mcp"
# ─── Config CRUD ────────────────────────────────────────
@router.post("/agents/{agent_id}/atlassian-channel", status_code=201)
async def configure_atlassian_channel(
agent_id: uuid.UUID,
data: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Configure Atlassian Rovo MCP for an agent.
Required field: api_key (Bearer token starting with ATSTT, or Basic base64(email:token)).
Optional: cloud_id (Atlassian cloud site ID for multi-site setups).
"""
agent, _ = await check_agent_access(db, current_user, agent_id)
if not is_agent_creator(current_user, agent):
raise HTTPException(status_code=403, detail="Only creator can configure channel")
api_key = (data.get("api_key") or "").strip()
if not api_key:
raise HTTPException(status_code=422, detail="api_key is required")
cloud_id = (data.get("cloud_id") or "").strip()
from app.core.security import encrypt_data
from app.config import get_settings
encrypted_key = encrypt_data(api_key, get_settings().SECRET_KEY)
result = await db.execute(
select(ChannelConfig).where(
ChannelConfig.agent_id == agent_id,
ChannelConfig.channel_type == "atlassian",
)
)
existing = result.scalar_one_or_none()
if existing:
existing.app_secret = encrypted_key
existing.is_configured = True
existing.extra_config = {**(existing.extra_config or {}), "cloud_id": cloud_id}
await db.commit()
# Sync tools for this agent in background
import asyncio
asyncio.create_task(_sync_atlassian_tools_for_agent(agent_id, api_key))
return _serialize(existing)
config = ChannelConfig(
agent_id=agent_id,
channel_type="atlassian",
app_id="atlassian",
app_secret=encrypted_key,
is_configured=True,
extra_config={"cloud_id": cloud_id},
)
db.add(config)
await db.commit()
await db.refresh(config)
# Sync tools for this agent in background
import asyncio
asyncio.create_task(_sync_atlassian_tools_for_agent(agent_id, api_key))
return _serialize(config)
@router.get("/agents/{agent_id}/atlassian-channel")
async def get_atlassian_channel(
agent_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_agent_access(db, current_user, agent_id)
result = await db.execute(
select(ChannelConfig).where(
ChannelConfig.agent_id == agent_id,
ChannelConfig.channel_type == "atlassian",
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="Atlassian not configured")
return _serialize(config)
@router.delete("/agents/{agent_id}/atlassian-channel", status_code=204)
async def delete_atlassian_channel(
agent_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
agent, _ = await check_agent_access(db, current_user, agent_id)
if not is_agent_creator(current_user, agent):
raise HTTPException(status_code=403, detail="Only creator can remove channel")
result = await db.execute(
select(ChannelConfig).where(
ChannelConfig.agent_id == agent_id,
ChannelConfig.channel_type == "atlassian",
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="Atlassian not configured")
await db.delete(config)
await db.commit()
@router.post("/agents/{agent_id}/atlassian-channel/test")
async def test_atlassian_channel(
agent_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Test connectivity to Atlassian Rovo MCP and list available tools."""
await check_agent_access(db, current_user, agent_id)
result = await db.execute(
select(ChannelConfig).where(
ChannelConfig.agent_id == agent_id,
ChannelConfig.channel_type == "atlassian",
)
)
config = result.scalar_one_or_none()
if not config or not config.app_secret:
raise HTTPException(status_code=400, detail="Atlassian not configured")
from app.services.mcp_client import MCPClient
try:
client = MCPClient(ATLASSIAN_MCP_URL, api_key=config.app_secret)
tools = await client.list_tools()
return {
"ok": True,
"tool_count": len(tools),
"tools": [{"name": t["name"], "description": t.get("description", "")[:100]} for t in tools[:10]],
"message": f"✅ Connected to Atlassian Rovo MCP — {len(tools)} tools available",
}
except Exception as e:
return {"ok": False, "error": str(e)[:300]}
# ─── Internal helper ────────────────────────────────────
def _serialize(config: ChannelConfig) -> dict:
return {
"id": str(config.id),
"agent_id": str(config.agent_id),
"channel_type": config.channel_type,
"is_configured": config.is_configured,
"is_connected": config.is_connected,
"cloud_id": (config.extra_config or {}).get("cloud_id", ""),
"extra_config": config.extra_config or {},
"created_at": config.created_at.isoformat() if config.created_at else None,
}
# ─── Utility for internal use ──────────────────────────
async def _sync_atlassian_tools_for_agent(agent_id: uuid.UUID, api_key: str) -> None:
"""Connect to Atlassian Rovo MCP and ensure all tools are seeded + assigned to this agent.
Discovers tools from the MCP server, creates Tool records if needed,
and creates AgentTool assignments for this specific agent.
"""
from app.services.mcp_client import MCPClient
from app.models.tool import Tool, AgentTool
from app.database import async_session
from sqlalchemy import select as sa_select
logger.info(f"[AtlassianChannel] Syncing tools for agent {agent_id} ...")
try:
client = MCPClient(ATLASSIAN_MCP_URL, api_key=api_key)
tools_discovered = await client.list_tools()
except Exception as e:
logger.error(f"[AtlassianChannel] Could not list tools: {e}")
return
if not tools_discovered:
logger.warning("[AtlassianChannel] No tools returned from Atlassian MCP")
return
logger.info(f"[AtlassianChannel] Found {len(tools_discovered)} tools, assigning to agent {agent_id}")
async with async_session() as db:
assigned = 0
for mcp_tool in tools_discovered:
raw_name = mcp_tool.get("name", "")
if not raw_name:
continue
tool_name = f"atlassian_rovo_{raw_name}"
tool_desc = mcp_tool.get("description", "")[:500]
tool_schema = mcp_tool.get("inputSchema", {"type": "object", "properties": {}})
if "jira" in raw_name.lower() or "issue" in raw_name.lower():
icon = "🔵"
elif "confluence" in raw_name.lower() or "page" in raw_name.lower():
icon = "📘"
elif "compass" in raw_name.lower() or "component" in raw_name.lower():
icon = "🧭"
else:
icon = "🔷"
# Ensure Tool record exists (shared across all agents)
tool_r = await db.execute(sa_select(Tool).where(Tool.name == tool_name))
tool = tool_r.scalar_one_or_none()
if not tool:
tool = Tool(
name=tool_name,
display_name=f"Atlassian: {raw_name}",
description=tool_desc,
type="mcp",
category="atlassian",
icon=icon,
parameters_schema=tool_schema,
mcp_server_url=ATLASSIAN_MCP_URL,
mcp_server_name="Atlassian Rovo",
mcp_tool_name=raw_name,
enabled=True,
is_default=False,
source="admin",
)
db.add(tool)
await db.flush()
else:
# Update schema in case it changed
tool.description = tool_desc
tool.parameters_schema = tool_schema
# Assign to this specific agent (api_key stored per-agent via channel config,
# but we also put it in AgentTool.config as fallback for _execute_mcp_tool)
at_r = await db.execute(
sa_select(AgentTool).where(
AgentTool.agent_id == agent_id,
AgentTool.tool_id == tool.id,
)
)
at = at_r.scalar_one_or_none()
if at:
at.enabled = True
at.config = {"api_key": api_key}
else:
db.add(AgentTool(
agent_id=agent_id,
tool_id=tool.id,
enabled=True,
source="user_installed",
installed_by_agent_id=agent_id,
config={"api_key": api_key},
))
assigned += 1
await db.commit()
logger.info(f"[AtlassianChannel] {assigned} new tool assignments for agent {agent_id}")
async def get_atlassian_api_key_for_agent(agent_id: uuid.UUID, db=None) -> str | None:
"""Return the configured Atlassian API key for the given agent, or None."""
from app.database import async_session
async def _fetch(session):
from app.core.security import decrypt_data
from app.config import get_settings
result = await session.execute(
select(ChannelConfig).where(
ChannelConfig.agent_id == agent_id,
ChannelConfig.channel_type == "atlassian",
ChannelConfig.is_configured == True,
)
)
config = result.scalar_one_or_none()
if not config or not config.app_secret:
return None
try:
return decrypt_data(config.app_secret, get_settings().SECRET_KEY)
except Exception:
return config.app_secret
if db is not None:
return await _fetch(db)
async with async_session() as session:
return await _fetch(session)