mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-29 04:54:49 +02:00
[eric] add Discord MCP integration with shared bot + per-user guild scoping
This commit is contained in:
@@ -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,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", "")
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user