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"
{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 &&