[eric] add Discord MCP integration with shared bot + per-user guild scoping

This commit is contained in:
ciregenz
2026-04-06 20:02:27 -07:00
parent 28b2a110ce
commit 077d9b4011
3 changed files with 114 additions and 3 deletions
+31 -1
View File
@@ -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)}")
+67
View File
@@ -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"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", 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", "")
+16 -2
View File
@@ -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: (
<svg viewBox="0 0 24 24" width="22" height="22">
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09a.09.09 0 0 0-.07-.03c-1.5.26-2.93.71-4.27 1.33a.07.07 0 0 0-.03.03c-2.72 4.07-3.47 8.03-3.1 11.95a.1.1 0 0 0 .04.07c1.83 1.34 3.6 2.16 5.34 2.7a.09.09 0 0 0 .1-.03c.41-.56.78-1.15 1.09-1.77a.09.09 0 0 0-.05-.13c-.58-.22-1.13-.49-1.66-.79a.09.09 0 0 1-.01-.16c.11-.08.22-.17.33-.25a.09.09 0 0 1 .09-.01c3.49 1.59 7.27 1.59 10.72 0a.09.09 0 0 1 .09.01c.11.09.22.17.33.26a.09.09 0 0 1-.01.16c-.53.31-1.08.57-1.66.79a.09.09 0 0 0-.05.13c.32.62.69 1.21 1.09 1.77a.09.09 0 0 0 .1.04c1.74-.54 3.51-1.36 5.34-2.7a.1.1 0 0 0 .04-.07c.44-4.53-.74-8.46-3.13-11.95a.07.07 0 0 0-.04-.04zM8.52 14.91c-1.04 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.04 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.83 2.12-1.89 2.12z" fill="#5865F2"/>
</svg>
),
authType: 'oauth2',
},
];
const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
@@ -1603,7 +1617,7 @@ const Tools: React.FC = () => {
</Box>
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
</Box>
{!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') && (
<Button
size="small"
variant="outlined"
@@ -1611,7 +1625,7 @@ const Tools: React.FC = () => {
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
Connect {tool.name}
{ig?.id === 'discord' && tool.auth_status === 'connected' ? 'Add server' : `Connect ${tool.name}`}
</Button>
)}
{!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && (