diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 2e9389ff..0ffab2db 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -152,7 +152,10 @@ class AgentManager: continue if tool.auth_type == "oauth2" and tool.auth_status == "connected": - if tool.name.lower() == "airtable": + if tool.name.lower() == "discord": + # Discord uses a shared bot token from .env, not user OAuth tokens. + refreshed = True + elif tool.name.lower() == "airtable": refreshed = await refresh_airtable_token(tool) elif tool.name.lower() == "hubspot": refreshed = await refresh_hubspot_token(tool) @@ -209,6 +212,33 @@ class AgentManager: f"\"{tool.connected_account_email}\" automatically — do NOT ask the user." ) + # Discord guild scoping — hard restriction. The bot may technically + # be in other servers (across other OpenSwarm users), but this + # specific user only authorized these guild IDs. + if tool.name.lower() == "discord": + guilds = tool.oauth_tokens.get("guilds") or [] + if guilds: + guild_descriptions = ", ".join( + f"{g.get('name', 'Unknown')} ({g.get('id', '')})" for g in guilds + ) + allowed_ids = [g.get("id", "") for g in guilds if g.get("id")] + lines.append( + f" AUTHORIZED DISCORD SERVERS (guild_ids): {guild_descriptions}" + ) + lines.append( + f" HARD RESTRICTION: You MUST only call Discord tools that operate on " + f"these guild_ids: {allowed_ids}. NEVER call Discord tools on any other " + f"guild_id even if the bot has access to it. NEVER list, search, or " + f"enumerate servers outside this list. If a user asks about a server " + f"not in this list, refuse and tell them to authorize it via the Connect " + f"Discord button. This is a security boundary, not a preference." + ) + else: + lines.append( + f" No Discord servers authorized yet. Tell the user to click " + f"'Connect Discord' to add a server before attempting any Discord actions." + ) + tool_names = list(tool_descs.keys()) if tool_names: lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}") diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index c87e779b..b01f73bf 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -67,6 +67,9 @@ AIRTABLE_SCOPES = [ HUBSPOT_AUTH_URL = "https://mcp-na2.hubspot.com/oauth/authorize/user" HUBSPOT_TOKEN_URL = "https://api.hubapi.com/oauth/v1/token" +DISCORD_AUTH_URL = "https://discord.com/oauth2/authorize" +DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token" + # Maps state -> {tool_id, code_verifier (for PKCE flows)} _pending_oauth: dict[str, dict] = {} @@ -211,6 +214,45 @@ async def oauth_callback(code: str = Query(...), state: str = Query("")): tool.auth_status = "connected" tool.connected_account_email = "HubSpot account" + elif tool.name.lower() == "discord": + # Discord bot install OAuth: exchange code, capture guild_id of the + # server the user added the bot to. Multiple connect calls APPEND + # additional guild_ids so users can authorize multiple servers. + client_id = os.environ.get("DISCORD_OAUTH_CLIENT_ID", "") + client_secret = os.environ.get("DISCORD_OAUTH_CLIENT_SECRET", "") + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(DISCORD_TOKEN_URL, data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + }, headers={ + "Content-Type": "application/x-www-form-urlencoded", + }) + + if resp.status_code != 200: + logger.warning(f"Discord OAuth token exchange failed: {resp.text}") + return HTMLResponse(f"

Token exchange failed

{resp.text}
", status_code=400) + + tokens = resp.json() + guild = tokens.get("guild") or {} + new_guild_id = guild.get("id", "") + new_guild_name = guild.get("name", "") + existing = tool.oauth_tokens.get("guilds") or [] + # Append unless this guild was already authorized + if new_guild_id and not any(g.get("id") == new_guild_id for g in existing): + existing.append({"id": new_guild_id, "name": new_guild_name}) + tool.oauth_tokens = { + # Bot token lives in .env, NEVER stored on the tool. We only + # track the list of authorized guilds for scope enforcement. + "guilds": existing, + } + tool.auth_type = "oauth2" + tool.auth_status = "connected" + names = ", ".join(g.get("name", "") for g in existing if g.get("name")) + tool.connected_account_email = f"{len(existing)} server{'s' if len(existing) != 1 else ''}" + (f" · {names}" if names else "") + elif tool.name.lower() == "notion": # Notion OAuth: Basic auth with client_id:secret notion_client_id = os.environ.get("NOTION_OAUTH_CLIENT_ID", "") @@ -515,6 +557,16 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: if client_secret: env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret + # Discord: bot token is loaded from .env at MCP launch time. It is NEVER + # stored on the tool definition or exposed to the frontend. The tool only + # tracks the list of authorized guild IDs (in oauth_tokens.guilds) which + # are used by the agent system prompt to scope what the agent may access. + if tool.name.lower() == "discord" and config.get("type") == "stdio": + bot_token = os.environ.get("DISCORD_BOT_TOKEN", "") + if bot_token: + env = config.setdefault("env", {}) + env["DISCORD_TOKEN"] = bot_token + # Microsoft 365 MCP: use a stable token cache path shared across process spawns if tool.name.lower() == "microsoft 365" and config.get("type") == "stdio": env = config.setdefault("env", {}) @@ -1153,6 +1205,21 @@ async def oauth_start(tool_id: str): "state": state, } auth_url = f"{HUBSPOT_AUTH_URL}?{urlencode(params)}" + elif tool.name.lower() == "discord": + client_id = os.environ.get("DISCORD_OAUTH_CLIENT_ID", "") + if not client_id: + raise HTTPException(status_code=400, detail="DISCORD_OAUTH_CLIENT_ID not set in backend .env") + permissions = os.environ.get("DISCORD_BOT_PERMISSIONS", "0") + _pending_oauth[state] = {"tool_id": tool_id} + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "bot identify", + "permissions": permissions, + "state": state, + } + auth_url = f"{DISCORD_AUTH_URL}?{urlencode(params)}" elif tool.name.lower() == "notion": _pending_oauth[state] = {"tool_id": tool_id} client_id = os.environ.get("NOTION_OAUTH_CLIENT_ID", "") diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index cbb36456..99e3f168 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -255,6 +255,20 @@ const INTEGRATIONS: Integration[] = [ { key: 'SLACK_MCP_XOXD_TOKEN', label: 'Slack Cookie (xoxd-...)', placeholder: 'Auto-detected via Sign in, or paste xoxd- cookie' }, ], }, + { + id: 'discord', + name: 'Discord', + description: 'Read messages, send messages, manage channels, and interact with Discord servers via the OpenSwarm bot.', + mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'mcp-discord'] }, + color: '#5865F2', + website: 'https://github.com/barryyip0625/mcp-discord', + icon: ( + + + + ), + authType: 'oauth2', + }, ]; const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling']; @@ -1603,7 +1617,7 @@ const Tools: React.FC = () => { {tool.description && {tool.description}} - {!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && tool.auth_status !== 'connected' && ( + {!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && ( )} {!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && (