[eric] OAuth + MCP polish: more reliable connect flow, Discord shim,

connected MCPs sort to top, Haiku-overflow warning, misc fixes, security risks fixes
This commit is contained in:
ciregenz
2026-04-26 15:14:16 -07:00
parent bb97438b66
commit c501b6340c
17 changed files with 1091 additions and 444 deletions
+2
View File
@@ -31,3 +31,5 @@ backend/.venv/
openswarm-cloud
.openswarm-cloud
.claude/
# Local-only operator helpers (never commit)
scripts/set-fly-*.sh
@@ -0,0 +1,8 @@
"""Stdio MCP shim that forwards Discord tool calls to the OpenSwarm cloud.
Run as: python -m backend.apps.discord_mcp_shim
"""
from backend.apps.discord_mcp_shim.server import main
if __name__ == "__main__":
main()
@@ -0,0 +1,5 @@
"""Module-level entrypoint so `python -m backend.apps.discord_mcp_shim` works."""
from backend.apps.discord_mcp_shim.server import main
if __name__ == "__main__":
main()
+459
View File
@@ -0,0 +1,459 @@
"""Stdio MCP shim for the Discord integration.
Carries no credentials. Each tool call is forwarded as a small HTTPS
request that includes a per-install identifier (used for rate-limiting).
The shim refuses operations against guilds not in OPENSWARM_DISCORD_GUILD_IDS
(set at spawn time from the user's authorized guild list).
stdlib-only on purpose so the subprocess starts fast.
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
PROXY_BASE = os.environ.get("OPENSWARM_OAUTH_BASE_URL", "https://api.openswarm.com").rstrip("/")
INSTALL_ID = os.environ.get("OPENSWARM_INSTALL_ID", "")
ALLOWED_GUILDS = set(
g for g in (os.environ.get("OPENSWARM_DISCORD_GUILD_IDS", "") or "").split(",") if g
)
# -- MCP tool definitions (exposed to the agent) ---------------------------
# Names match the original mcp-discord surface so prompts that referenced
# `discord_send` etc. keep working. inputSchema deliberately matches what
# the original package documented.
TOOLS = [
{
"name": "discord_login",
"description": "Verify the Discord bot helper is reachable. Returns the bot's joined guilds.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "discord_get_server_info",
"description": "Get metadata for a Discord guild (server) the bot is a member of.",
"inputSchema": {
"type": "object",
"properties": {"guild_id": {"type": "string"}},
"required": ["guild_id"],
},
},
{
"name": "discord_list_channels",
"description": "List all channels in a Discord guild.",
"inputSchema": {
"type": "object",
"properties": {"guild_id": {"type": "string"}},
"required": ["guild_id"],
},
},
{
"name": "discord_create_text_channel",
"description": "Create a new text channel in a guild.",
"inputSchema": {
"type": "object",
"properties": {
"guild_id": {"type": "string"},
"name": {"type": "string"},
"parent_id": {"type": "string", "description": "Optional category ID"},
"topic": {"type": "string"},
},
"required": ["guild_id", "name"],
},
},
{
"name": "discord_create_category",
"description": "Create a new category (parent) in a guild.",
"inputSchema": {
"type": "object",
"properties": {
"guild_id": {"type": "string"},
"name": {"type": "string"},
},
"required": ["guild_id", "name"],
},
},
{
"name": "discord_edit_category",
"description": "Rename or modify a category channel.",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"name": {"type": "string"},
},
"required": ["channel_id"],
},
},
{
"name": "discord_delete_category",
"description": "Delete a category channel.",
"inputSchema": {
"type": "object",
"properties": {"channel_id": {"type": "string"}},
"required": ["channel_id"],
},
},
{
"name": "discord_delete_channel",
"description": "Delete a channel by ID.",
"inputSchema": {
"type": "object",
"properties": {"channel_id": {"type": "string"}},
"required": ["channel_id"],
},
},
{
"name": "discord_send",
"description": "Send a message to a Discord channel.",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"content": {"type": "string"},
},
"required": ["channel_id", "content"],
},
},
{
"name": "discord_read_messages",
"description": "Read recent messages from a Discord channel (most recent first).",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"limit": {"type": "integer", "default": 50, "description": "1-100"},
},
"required": ["channel_id"],
},
},
{
"name": "discord_add_reaction",
"description": "Add an emoji reaction to a message (as the bot).",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"message_id": {"type": "string"},
"emoji": {
"type": "string",
"description": "Unicode emoji (e.g. 👍) or name:id custom emoji",
},
},
"required": ["channel_id", "message_id", "emoji"],
},
},
{
"name": "discord_add_multiple_reactions",
"description": "Add multiple emoji reactions to a message.",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"message_id": {"type": "string"},
"emojis": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["channel_id", "message_id", "emojis"],
},
},
{
"name": "discord_get_forum_channels",
"description": "List forum-type channels in a guild.",
"inputSchema": {
"type": "object",
"properties": {"guild_id": {"type": "string"}},
"required": ["guild_id"],
},
},
{
"name": "discord_create_forum_post",
"description": "Create a forum thread/post in a forum channel.",
"inputSchema": {
"type": "object",
"properties": {
"forum_id": {"type": "string"},
"name": {"type": "string", "description": "Thread title"},
"content": {"type": "string", "description": "First message body"},
},
"required": ["forum_id", "name", "content"],
},
},
{
"name": "discord_get_forum_post",
"description": "Get a single message from a forum post.",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"message_id": {"type": "string"},
},
"required": ["channel_id", "message_id"],
},
},
{
"name": "discord_reply_to_forum",
"description": "Reply to a forum thread.",
"inputSchema": {
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"content": {"type": "string"},
},
"required": ["channel_id", "content"],
},
},
]
# -- HTTP plumbing ---------------------------------------------------------
def _call(
method: str,
path: str,
*,
body: dict | None = None,
query: dict | None = None,
timeout: float = 30.0,
) -> tuple[int, dict | str]:
"""Single hop to the Discord helper service. Returns (status, parsed-body-or-text).
install_id header attribution is mandatory server-side; if empty we fail
locally so the user gets a clear error instead of an opaque 401.
"""
if not INSTALL_ID:
return 0, "OPENSWARM_INSTALL_ID env var not set — cannot call Discord proxy"
url = f"{PROXY_BASE}/api/discord{path}"
if query:
url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
headers = {
"X-OpenSwarm-Install-Id": INSTALL_ID,
"Accept": "application/json",
}
data: bytes | None = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
text = resp.read().decode("utf-8", errors="replace")
try:
return resp.status, json.loads(text) if text else {}
except json.JSONDecodeError:
return resp.status, text
except urllib.error.HTTPError as e:
text = ""
try:
text = e.read().decode("utf-8", errors="replace") if e.fp else ""
except Exception:
pass
try:
return e.code, json.loads(text) if text else {}
except json.JSONDecodeError:
return e.code, text or str(e)
except urllib.error.URLError as e:
return 0, f"Helper service unreachable: {e.reason}"
except Exception as e:
return 0, f"Request failed: {e!r}"
def _err(text: str) -> dict:
return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True}
def _ok(payload) -> dict:
if isinstance(payload, str):
return {"content": [{"type": "text", "text": payload}]}
return {"content": [{"type": "text", "text": json.dumps(payload, indent=2, default=str)}]}
def _check_guild(guild_id: str) -> str | None:
"""Return an error string if guild_id is outside the user-authorized set, else None.
The set is sourced from OPENSWARM_DISCORD_GUILD_IDS env var (CSV) which
tools_lib.py populates from the tool's oauth_tokens.guilds. If the env
var is empty (no guild authorization yet), allow all — agent shouldn't
be able to spawn this MCP without an OAuth flow having happened.
"""
if not ALLOWED_GUILDS:
return None # nothing to enforce yet
if guild_id not in ALLOWED_GUILDS:
return (
f"Guild {guild_id} is not authorized for this OpenSwarm install. "
f"Authorized guilds: {sorted(ALLOWED_GUILDS)}"
)
return None
# -- Tool implementations --------------------------------------------------
def handle_tool_call(name: str, args: dict) -> dict:
if name == "discord_login":
status, body = _call("GET", "/users/@me/guilds")
if status != 200:
return _err(f"Discord proxy unreachable (HTTP {status}): {body}")
return _ok({"connected": True, "guilds": body})
if name == "discord_get_server_info":
gid = str(args.get("guild_id", ""))
if (e := _check_guild(gid)): return _err(e)
status, body = _call("GET", f"/guilds/{gid}")
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
if name == "discord_list_channels":
gid = str(args.get("guild_id", ""))
if (e := _check_guild(gid)): return _err(e)
status, body = _call("GET", f"/guilds/{gid}/channels")
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
if name == "discord_create_text_channel":
gid = str(args.get("guild_id", ""))
if (e := _check_guild(gid)): return _err(e)
payload: dict = {"name": args.get("name", ""), "type": 0}
if args.get("parent_id"): payload["parent_id"] = args["parent_id"]
if args.get("topic"): payload["topic"] = args["topic"]
status, body = _call("POST", f"/guilds/{gid}/channels", body=payload)
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
if name == "discord_create_category":
gid = str(args.get("guild_id", ""))
if (e := _check_guild(gid)): return _err(e)
status, body = _call("POST", f"/guilds/{gid}/channels", body={"name": args.get("name", ""), "type": 4})
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
if name == "discord_edit_category":
cid = str(args.get("channel_id", ""))
payload: dict = {}
if args.get("name"): payload["name"] = args["name"]
status, body = _call("PATCH", f"/channels/{cid}", body=payload)
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
if name == "discord_delete_category" or name == "discord_delete_channel":
cid = str(args.get("channel_id", ""))
status, body = _call("DELETE", f"/channels/{cid}")
return _ok({"deleted": True}) if status in (200, 204) else _err(f"HTTP {status}: {body}")
if name == "discord_send":
cid = str(args.get("channel_id", ""))
content = str(args.get("content", ""))
status, body = _call("POST", f"/channels/{cid}/messages", body={"content": content})
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
if name == "discord_read_messages":
cid = str(args.get("channel_id", ""))
limit = max(1, min(int(args.get("limit", 50) or 50), 100))
status, body = _call("GET", f"/channels/{cid}/messages", query={"limit": limit})
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
if name == "discord_add_reaction":
cid = str(args.get("channel_id", ""))
mid = str(args.get("message_id", ""))
emoji = str(args.get("emoji", ""))
# Discord's URL needs the emoji urlencoded; passes through.
status, body = _call("PUT", f"/channels/{cid}/messages/{mid}/reactions/{urllib.parse.quote(emoji, safe='')}/@me")
return _ok({"added": emoji}) if status in (200, 204) else _err(f"HTTP {status}: {body}")
if name == "discord_add_multiple_reactions":
cid = str(args.get("channel_id", ""))
mid = str(args.get("message_id", ""))
emojis = args.get("emojis", []) or []
results = []
for e in emojis:
status, body = _call("PUT", f"/channels/{cid}/messages/{mid}/reactions/{urllib.parse.quote(str(e), safe='')}/@me")
results.append({"emoji": e, "ok": status in (200, 204), "status": status})
return _ok({"reactions": results})
if name == "discord_get_forum_channels":
gid = str(args.get("guild_id", ""))
if (e := _check_guild(gid)): return _err(e)
status, body = _call("GET", f"/guilds/{gid}/channels")
if status != 200: return _err(f"HTTP {status}: {body}")
# Filter to type 15 (forum). Discord channel types reference:
# GUILD_FORUM = 15
forums = [ch for ch in (body or []) if isinstance(ch, dict) and ch.get("type") == 15]
return _ok(forums)
if name == "discord_create_forum_post":
fid = str(args.get("forum_id", ""))
status, body = _call("POST", f"/channels/{fid}/threads", body={
"name": args.get("name", ""),
"message": {"content": args.get("content", "")},
})
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
if name == "discord_get_forum_post":
cid = str(args.get("channel_id", ""))
mid = str(args.get("message_id", ""))
status, body = _call("GET", f"/channels/{cid}/messages/{mid}")
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
if name == "discord_reply_to_forum":
cid = str(args.get("channel_id", ""))
content = str(args.get("content", ""))
status, body = _call("POST", f"/channels/{cid}/messages", body={"content": content})
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
return _err(f"Unknown tool: {name}")
# -- JSON-RPC stdio loop ---------------------------------------------------
def _send(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {}) or {}
if method == "initialize":
_send(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "openswarm-discord", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
_send(id_, {"tools": TOOLS})
elif method == "tools/call":
name = params.get("name", "")
args = params.get("arguments", {}) or {}
try:
_send(id_, handle_tool_call(name, args))
except Exception as e:
_send(id_, _err(f"shim crashed: {e!r}"))
elif method == "ping":
_send(id_, {})
elif id_ is not None:
_send(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
+349 -384
View File
@@ -16,18 +16,16 @@ import httpx
from dotenv import load_dotenv
from fastapi import HTTPException, Query
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from backend.config.Apps import SubApp
from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate, BUILTIN_TOOLS
logger = logging.getLogger(__name__)
# Default Google OAuth credentials for the OpenSwarm project.
# These are public credentials for a desktop/web OAuth client (safe to embed per Google's docs).
# Users can override via GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars.
_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com"
_DEFAULT_GOOGLE_CLIENT_SECRET = "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"
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID)
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET)
# Base URL for the OAuth helper service. Override via env in dev if needed.
OPENSWARM_OAUTH_BASE_URL = os.environ.get(
"OPENSWARM_OAUTH_BASE_URL", "https://api.openswarm.com"
).rstrip("/")
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
@@ -44,35 +42,27 @@ async def tools_lib_lifespan():
tools_lib = SubApp("tools", tools_lib_lifespan)
# Most providers go through a small HTTP claim handoff. Google uses a direct
# local callback. Both flows return an auto-closing HTML page when done.
# Google OAuth (local callback flow).
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
GOOGLE_SCOPES = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"email",
"profile",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/presentations",
]
AIRTABLE_AUTH_URL = "https://airtable.com/oauth2/v1/authorize"
AIRTABLE_TOKEN_URL = "https://airtable.com/oauth2/v1/token"
AIRTABLE_SCOPES = [
"data.records:read", "data.records:write",
"data.recordComments:read", "data.recordComments:write",
"schema.bases:read", "schema.bases:write",
"user.email:read",
]
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)}
# Per-flow state for the local Google OAuth callback. Keyed by the OAuth
# `state` param; value is {tool_id} (PKCE verifier not used for Google).
_pending_oauth: dict[str, dict] = {}
@@ -142,193 +132,84 @@ async def list_tools():
@tools_lib.router.get("/oauth/callback")
async def oauth_callback(code: str = Query(...), state: str = Query("")):
"""Local Google OAuth callback (v1.0.25 flow, Google-only in v1.0.26).
Notion / Airtable / HubSpot / Discord all flow through the cloud and
land at /oauth/cloud-claim instead. This endpoint stays Google-only
because the production Google OAuth client has localhost registered
and the user doesn't control the console to add a cloud URL.
"""
pending = _pending_oauth.pop(state, None)
if not pending:
return HTMLResponse("<html><body><h2>Invalid OAuth state</h2></body></html>", status_code=400)
tool_id = pending if isinstance(pending, str) else pending["tool_id"]
code_verifier = pending.get("code_verifier") if isinstance(pending, dict) else None
return HTMLResponse(
"<html><body><h2>Invalid OAuth state</h2></body></html>",
status_code=400,
)
tool_id = pending["tool_id"] if isinstance(pending, dict) else pending
tool = _load(tool_id)
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
if not client_id or not client_secret:
return HTMLResponse(
"<html><body><h2>Google OAuth not configured</h2><p>"
"GOOGLE_OAUTH_CLIENT_ID/SECRET missing from .env</p></body></html>",
status_code=500,
)
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
if tool.name.lower() == "airtable":
# Airtable OAuth: PKCE flow
client_id = os.environ.get("AIRTABLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("AIRTABLE_OAUTH_CLIENT_SECRET", "")
import base64
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(AIRTABLE_TOKEN_URL, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier or "",
"client_id": client_id,
}, headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/x-www-form-urlencoded",
})
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
})
if resp.status_code != 200:
logger.warning("Google OAuth token exchange failed: %s", resp.text[:240])
return HTMLResponse(
f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>",
status_code=400,
)
if resp.status_code != 200:
logger.warning(f"Airtable 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()
access_token = tokens.get("access_token", "")
tool.oauth_tokens = {
"access_token": access_token,
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 3600),
}
tool.auth_type = "oauth2"
tool.auth_status = "connected"
tokens = resp.json()
tool.oauth_tokens = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 7200),
}
tool.auth_type = "oauth2"
tool.auth_status = "connected"
tool.connected_account_email = "Airtable account"
elif tool.name.lower() == "hubspot":
# HubSpot OAuth 2.1: PKCE flow
client_id = os.environ.get("HUBSPOT_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("HUBSPOT_OAUTH_CLIENT_SECRET", "")
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(HUBSPOT_TOKEN_URL, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
"code_verifier": code_verifier or "",
}, headers={
"Content-Type": "application/x-www-form-urlencoded",
})
if resp.status_code != 200:
logger.warning(f"HubSpot 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()
tool.oauth_tokens = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 1800),
}
tool.auth_type = "oauth2"
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", "")
notion_client_secret = os.environ.get("NOTION_OAUTH_CLIENT_SECRET", "")
import base64
credentials = base64.b64encode(f"{notion_client_id}:{notion_client_secret}".encode()).decode()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post("https://api.notion.com/v1/oauth/token", json={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
}, headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/json",
})
if resp.status_code != 200:
logger.warning(f"Notion 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()
tool.oauth_tokens = {
"access_token": tokens.get("access_token", ""),
}
tool.auth_type = "oauth2"
tool.auth_status = "connected"
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
else:
# Google OAuth
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
})
if resp.status_code != 200:
logger.warning(f"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()
access_token = tokens.get("access_token", "")
tool.oauth_tokens = {
"access_token": access_token,
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 3600),
}
tool.auth_type = "oauth2"
tool.auth_status = "connected"
if access_token:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
except Exception as e:
logger.warning(f"Failed to fetch Google userinfo: {e}")
if access_token:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
except Exception as e:
logger.warning("Failed to fetch Google userinfo: %s", e)
_save(tool)
return _connected_html()
def _connected_html() -> HTMLResponse:
"""v1.0.25-style auto-close page. Same markup so the UX is unchanged."""
return HTMLResponse("""
<html><body>
<h2 style="font-family:sans-serif;color:#22c55e">Connected successfully!</h2>
<p style="font-family:sans-serif;color:#666">You can close this window.</p>
<script>
if (window.opener) window.opener.postMessage({type:'oauth_complete', tool_id:'""" + tool_id + """'}, '*');
setTimeout(() => window.close(), 1500);
if (window.opener) window.opener.postMessage({type:'oauth_complete'}, '*');
setTimeout(function(){ window.close(); }, 1500);
</script>
</body></html>
""")
@@ -491,15 +372,24 @@ 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.
# Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim).
# We pass install_id + base URL via env so the shim subprocess doesn't
# need to import backend.config.* itself.
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
from backend.config.install_id import get_install_id
env = config.setdefault("env", {})
env["OPENSWARM_OAUTH_BASE_URL"] = OPENSWARM_OAUTH_BASE_URL
env["OPENSWARM_INSTALL_ID"] = get_install_id()
# Pass the authorized guild IDs so the shim can scope-enforce.
guild_ids = [g.get("id", "") for g in (tool.oauth_tokens.get("guilds") or []) if g.get("id")]
if guild_ids:
env["OPENSWARM_DISCORD_GUILD_IDS"] = ",".join(guild_ids)
# The shim runs as a subprocess and needs to import
# `backend.apps.discord_mcp_shim` — set PYTHONPATH to the project
# root (parent of the backend/ dir) so that import resolves.
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
env["PYTHONPATH"] = (_project_root + os.pathsep + existing_pp) if existing_pp else _project_root
# Microsoft 365 MCP: use a stable token cache path shared across process spawns
if tool.name.lower() == "microsoft 365" and config.get("type") == "stdio":
@@ -511,6 +401,17 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if config.get("type") == "stdio":
if config.get("command"):
# `python` (no version suffix) doesn't exist on a stock macOS,
# so a tool config that asks for "python" silently fails to
# spawn — Claude Agent SDK then exposes zero tools from that
# MCP. We resolve to the actual interpreter running the
# backend (sys.executable), which is guaranteed to exist and
# have backend modules importable. `python3` and absolute
# paths pass through unchanged.
if config["command"] == "python":
resolved_python = sys.executable or shutil.which("python3") or shutil.which("python")
if resolved_python:
config["command"] = resolved_python
# Check for bundled npm MCP servers — use Electron's Node.js instead of npx
if config["command"] in ("npx", "bunx"):
pkg_name = next((a for a in (config.get("args") or []) if not a.startswith("-")), None)
@@ -965,7 +866,24 @@ _m365_login_processes: dict[str, dict] = {} # tool_id -> {proc, device_code, st
def _m365_server_script() -> str:
"""Return the on-disk path to the bundled MS365 MCP server entry.
v1.0.26 replaced the heavy backend/npm-servers/softeria-ms-365-mcp-server/
node_modules tree (~93MB / 11k files) with a single esbuild bundle at
backend/mcp-bundles/softeria-ms-365-mcp-server/dist/index.js (4.7MB).
The new path mirrors the SDK's internal layout (dist/index.js + sibling
package.json) because cli.js reads __dirname/../package.json for the
--version flag — see scripts/build-app.sh `build_mcp_bundle_dir`.
"""
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
bundle = os.path.join(
_backend, "mcp-bundles", "softeria-ms-365-mcp-server", "dist", "index.js",
)
if os.path.isfile(bundle):
return bundle
# Fallback for any user still on a v1.0.25 install whose backend/ folder
# was left over from before the bundle migration. Will return the legacy
# path; if that doesn't exist either, the caller raises a clear error.
return os.path.join(
_backend, "npm-servers", "softeria-ms-365-mcp-server",
"node_modules", "@softeria", "ms-365-mcp-server", "dist", "index.js",
@@ -1148,101 +1066,216 @@ async def oauth_disconnect(tool_id: str):
return {"ok": True, "tool": tool.model_dump()}
# Tool name → provider key for the OAuth helper service. Google is not in
# this map (it uses the direct local callback); anything else falls back to
# the local Google flow.
_TOOL_NAME_TO_PROVIDER = {
"airtable": "airtable",
"hubspot": "hubspot",
"discord": "discord",
"notion": "notion",
}
def _proxied_provider_for(tool: ToolDefinition) -> Optional[str]:
return _TOOL_NAME_TO_PROVIDER.get(tool.name.lower())
@tools_lib.router.post("/{tool_id}/oauth/start")
async def oauth_start(tool_id: str):
"""Return the OAuth start URL for this tool."""
tool = _load(tool_id)
proxied = _proxied_provider_for(tool)
_port = os.environ.get("OPENSWARM_PORT", "8324")
if proxied:
from backend.config.install_id import get_install_id
install_id = get_install_id()
params = {
"install_id": install_id,
"tool_id": tool_id,
"local_port": _port,
}
auth_url = (
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/{proxied}/start?"
f"{urlencode(params)}"
)
return {"auth_url": auth_url}
# Local Google flow. State is a one-shot CSRF nonce keyed to the tool.
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(
status_code=400,
detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env",
)
state = secrets.token_urlsafe(24)
_pending_oauth[state] = {"tool_id": tool_id}
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
state = tool_id
if tool.name.lower() == "airtable":
client_id = os.environ.get("AIRTABLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(status_code=400, detail="AIRTABLE_OAUTH_CLIENT_ID not set in backend .env")
# PKCE: generate code_verifier and code_challenge
code_verifier = secrets.token_urlsafe(96)
code_challenge = hashlib.sha256(code_verifier.encode()).digest()
import base64
code_challenge_b64 = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode()
_pending_oauth[state] = {"tool_id": tool_id, "code_verifier": code_verifier}
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(AIRTABLE_SCOPES),
"state": state,
"code_challenge": code_challenge_b64,
"code_challenge_method": "S256",
}
auth_url = f"{AIRTABLE_AUTH_URL}?{urlencode(params)}"
elif tool.name.lower() == "hubspot":
client_id = os.environ.get("HUBSPOT_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(status_code=400, detail="HUBSPOT_OAUTH_CLIENT_ID not set in backend .env")
code_verifier = secrets.token_urlsafe(96)
code_challenge = hashlib.sha256(code_verifier.encode()).digest()
import base64
code_challenge_b64 = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode()
_pending_oauth[state] = {"tool_id": tool_id, "code_verifier": code_verifier}
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_challenge": code_challenge_b64,
"code_challenge_method": "S256",
"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", "")
if not client_id:
raise HTTPException(status_code=400, detail="NOTION_OAUTH_CLIENT_ID not set in backend .env")
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"owner": "user",
"state": state,
}
auth_url = f"https://api.notion.com/v1/oauth/authorize?{urlencode(params)}"
else:
_pending_oauth[state] = {"tool_id": tool_id}
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(GOOGLE_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": state,
}
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(GOOGLE_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": state,
}
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
return {"auth_url": auth_url}
@tools_lib.router.get("/oauth/cloud-claim")
async def oauth_cloud_claim(
session_id: str = Query(...),
tool_id: str = Query(...),
):
"""Browser-facing callback for the proxied OAuth flow.
Receives a single-use session_id, exchanges it for the tokens (using
install_id as the binding), persists them, and serves an auto-close page.
"""
from backend.config.install_id import get_install_id
install_id = get_install_id()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/session/{session_id}/claim",
json={"install_id": install_id},
)
except Exception as e:
logger.exception("Cloud OAuth claim threw: %s", e)
return HTMLResponse(
f"<html><body><h2>Connection failed</h2><pre>{e}</pre>"
f"<p>Please retry from OpenSwarm.</p></body></html>",
status_code=502,
)
if resp.status_code in (404, 410):
return HTMLResponse(
"<html><body><h2>Session expired</h2>"
"<p>Please retry from OpenSwarm.</p></body></html>",
status_code=410,
)
if resp.status_code == 403:
return HTMLResponse(
"<html><body><h2>OAuth session not bound to this install</h2>"
"<p>Please retry from OpenSwarm.</p></body></html>",
status_code=403,
)
if resp.status_code != 200:
logger.warning("Cloud OAuth claim failed: HTTP %d %s", resp.status_code, resp.text[:200])
return HTMLResponse(
f"<html><body><h2>Cloud OAuth claim failed</h2><pre>{resp.text}</pre></body></html>",
status_code=502,
)
data = resp.json()
tokens = data.get("tokens", {}) or {}
tool = _load(tool_id)
_persist_cloud_tokens(tool, tokens)
_save(tool)
return _connected_html()
def _persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None:
"""Normalise the cloud's claim response into tool.oauth_tokens.
Per-provider shaping mirrors what the v1.0.25 local-callback flow used
to write — the rest of the app (refresh helpers, MCP env injection)
expects exactly this shape.
"""
name = tool.name.lower()
if name == "discord":
new_guilds = (tokens.get("_guilds") or []) if isinstance(tokens, dict) else []
existing = tool.oauth_tokens.get("guilds") or []
for g in new_guilds:
if g.get("id") and not any(e.get("id") == g["id"] for e in existing):
existing.append({"id": g["id"], "name": g.get("name", "")})
tool.oauth_tokens = {"guilds": existing}
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 name == "notion":
tool.oauth_tokens = {"access_token": tokens.get("access_token", "")}
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
else:
tool.oauth_tokens = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + (tokens.get("expires_in") or 3600),
}
tool.connected_account_email = (
tokens.get("hub_domain") # HubSpot
or tokens.get("workspace_name")
or f"{tool.name} account"
)
tool.auth_type = "oauth2"
tool.auth_status = "connected"
async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry: int) -> Optional[str]:
"""Refresh an OAuth access_token by POSTing the refresh_token to the
helper service. Per-provider wrappers below pass a default expires_in
fallback for providers that don't return one.
"""
if tool.auth_type != "oauth2":
return None
refresh_token = tool.oauth_tokens.get("refresh_token")
if not refresh_token:
return None
expiry = tool.oauth_tokens.get("token_expiry", 0)
if time.time() < expiry - 60:
return tool.oauth_tokens.get("access_token")
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/{provider}/refresh",
json={"refresh_token": refresh_token},
)
if resp.status_code == 401:
# Provider rejected — user revoked at the provider's side. Mark
# as needing re-auth so the UI prompts a Reconnect.
tool.auth_status = "expired"
_save(tool)
logger.warning(f"{provider} refresh rejected (user revoked); marking tool as expired")
return None
if resp.status_code != 200:
logger.warning(f"{provider} cloud refresh failed: HTTP %d %s", resp.status_code, resp.text[:200])
return None
data = (resp.json() or {}).get("tokens") or {}
new_token = data.get("access_token", "")
if not new_token:
return None
tool.oauth_tokens["access_token"] = new_token
tool.oauth_tokens["token_expiry"] = time.time() + (data.get("expires_in") or default_expiry)
if data.get("refresh_token"):
# Some providers (HubSpot, Airtable) rotate refresh_tokens on every
# refresh. Persist the new one or future refreshes will fail.
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
# Backfill identity label on first successful refresh after upgrade.
if not tool.connected_account_email and data.get("email"):
tool.connected_account_email = data["email"]
_save(tool)
return new_token
except Exception as e:
logger.warning(f"{provider} cloud refresh exception for tool {tool.id}: {e}")
return None
async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired Google OAuth token. Returns the fresh access_token or None."""
"""Refresh an expired Google OAuth token using the local client_secret.
Google stays on the v1.0.25 local flow because we don't control the
Google Cloud Console for the production OAuth client and can't add the
cloud's redirect URI. The client_secret ships in the production .env —
standard "public OAuth app" pattern.
"""
if tool.auth_type != "oauth2":
return None
refresh_token = tool.oauth_tokens.get("refresh_token")
@@ -1265,106 +1298,38 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
"refresh_token": refresh_token,
"grant_type": "refresh_token",
})
if resp.status_code == 200:
data = resp.json()
new_token = data["access_token"]
tool.oauth_tokens["access_token"] = new_token
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600)
if not tool.connected_account_email:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {new_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
except Exception:
pass
_save(tool)
return new_token
if resp.status_code != 200:
logger.warning("Google token refresh failed: HTTP %d %s", resp.status_code, resp.text[:200])
return None
data = resp.json()
new_token = data.get("access_token", "")
if not new_token:
return None
tool.oauth_tokens["access_token"] = new_token
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600)
if not tool.connected_account_email:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {new_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
except Exception:
pass
_save(tool)
return new_token
except Exception as e:
logger.warning(f"Google token refresh failed for tool {tool.id}: {e}")
return None
logger.warning("Google token refresh exception for tool %s: %s", tool.id, e)
return None
async def refresh_airtable_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired Airtable OAuth token. Returns the fresh access_token or None."""
if tool.auth_type != "oauth2":
return None
refresh_token = tool.oauth_tokens.get("refresh_token")
if not refresh_token:
return None
expiry = tool.oauth_tokens.get("token_expiry", 0)
if time.time() < expiry - 60:
return tool.oauth_tokens.get("access_token")
client_id = os.environ.get("AIRTABLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("AIRTABLE_OAUTH_CLIENT_SECRET", "")
if not client_id or not client_secret:
return None
try:
import base64
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(AIRTABLE_TOKEN_URL, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
}, headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/x-www-form-urlencoded",
})
if resp.status_code == 200:
data = resp.json()
tool.oauth_tokens["access_token"] = data["access_token"]
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 7200)
if data.get("refresh_token"):
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
_save(tool)
return data["access_token"]
except Exception as e:
logger.warning(f"Airtable token refresh failed for tool {tool.id}: {e}")
return None
"""Refresh an expired Airtable OAuth access_token."""
return await _refresh_via_proxy("airtable", tool, default_expiry=7200)
async def refresh_hubspot_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired HubSpot OAuth token. Returns the fresh access_token or None."""
if tool.auth_type != "oauth2":
return None
refresh_token = tool.oauth_tokens.get("refresh_token")
if not refresh_token:
return None
expiry = tool.oauth_tokens.get("token_expiry", 0)
if time.time() < expiry - 60:
return tool.oauth_tokens.get("access_token")
client_id = os.environ.get("HUBSPOT_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("HUBSPOT_OAUTH_CLIENT_SECRET", "")
if not client_id or not client_secret:
return None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(HUBSPOT_TOKEN_URL, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
}, headers={
"Content-Type": "application/x-www-form-urlencoded",
})
if resp.status_code == 200:
data = resp.json()
tool.oauth_tokens["access_token"] = data["access_token"]
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 1800)
if data.get("refresh_token"):
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
_save(tool)
return data["access_token"]
except Exception as e:
logger.warning(f"HubSpot token refresh failed for tool {tool.id}: {e}")
return None
"""Refresh an expired HubSpot OAuth access_token."""
return await _refresh_via_proxy("hubspot", tool, default_expiry=1800)
+4
View File
@@ -95,6 +95,10 @@ _AUTH_EXEMPT_EXACT = {
# Without this exemption the redirect lands a 401 page in the user's
# browser — see tools_lib.py:1156 where redirect_uri is constructed.
"/api/tools/oauth/callback",
# Browser-redirect target for the proxied OAuth claim handoff. Browser
# has no way to inject our bearer token; the install_id check inside
# the handler is what binds the request to this user.
"/api/tools/oauth/cloud-claim",
"/api/version",
}
+66
View File
@@ -0,0 +1,66 @@
"""Per-install identifier.
A UUID4 generated on first run, persisted at ``<DATA_ROOT>/install_id``
with 0600 perms. Used to bind an in-flight OAuth claim to the install
that started it, so a leaked session_id alone is useless.
Not a secret. Not a user identity. Not stable across reinstalls
(reinstalling generates a new ID, by design — the previous install's
in-flight OAuth flows shouldn't follow the user across reinstalls).
"""
from __future__ import annotations
import os
import uuid
from backend.config.paths import DATA_ROOT
_INSTALL_ID_FILE = os.path.join(DATA_ROOT, "install_id")
_cached: str | None = None
def get_install_id() -> str:
"""Return the persistent install_id, generating + persisting on first call.
Idempotent across processes — if the file already exists we read it.
Concurrent first-call from two processes is safe: both write a UUID,
last-writer-wins, neither side cares which one is canonical.
"""
global _cached
if _cached:
return _cached
try:
with open(_INSTALL_ID_FILE, "r", encoding="utf-8") as f:
existing = f.read().strip()
if _looks_like_uuid(existing):
_cached = existing
return _cached
except FileNotFoundError:
pass
except Exception:
# Corrupt file — overwrite below.
pass
fresh = str(uuid.uuid4())
os.makedirs(os.path.dirname(_INSTALL_ID_FILE) or ".", exist_ok=True)
# 0600 so other accounts on the same machine can't read it. We're not
# treating it as a secret, but no reason to be sloppy.
fd = os.open(_INSTALL_ID_FILE, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
try:
os.write(fd, fresh.encode("utf-8"))
finally:
os.close(fd)
_cached = fresh
return _cached
def _looks_like_uuid(s: str) -> bool:
if len(s) != 36:
return False
try:
uuid.UUID(s)
return True
except ValueError:
return False
+27 -6
View File
@@ -30,10 +30,25 @@ let pendingDeepLink = null;
function forwardDeepLinkToRenderer(url) {
if (!url) return;
// openswarm:// URLs split by host: "auth" → subscription token,
// "oauth/{provider}/complete" → OAuth claim. Each goes to its own
// IPC channel so the renderer can route without parsing twice.
let channel = 'openswarm:auth-url';
try {
const u = new URL(url);
if (u.host === 'oauth' && u.pathname.endsWith('/complete')) {
channel = 'openswarm:oauth-claim';
}
} catch (_) {
// Malformed URL — fall back to legacy channel; renderer ignores anything
// it doesn't recognise.
}
if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) {
mainWindow.webContents.send('openswarm:auth-url', url);
mainWindow.webContents.send(channel, url);
} else {
pendingDeepLink = url;
// Stash both URL and target channel so we can flush correctly when
// the renderer is ready. Replaces the simple string with a {channel,url}.
pendingDeepLink = { channel, url };
}
}
@@ -558,10 +573,15 @@ function createWindow() {
});
// Once the renderer has loaded, flush any deep-link URL we captured before
// the window existed (cold-launch via openswarm://).
// the window existed (cold-launch via openswarm://). pendingDeepLink may
// be a string (legacy) OR a {channel, url} object (v1.0.26+ OAuth claims).
mainWindow.webContents.once('did-finish-load', () => {
if (pendingDeepLink) {
mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
if (typeof pendingDeepLink === 'string') {
mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
} else {
mainWindow.webContents.send(pendingDeepLink.channel, pendingDeepLink.url);
}
pendingDeepLink = null;
}
});
@@ -647,9 +667,10 @@ function killBackend() {
app.whenReady().then(async () => {
// Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
// in argv; macOS fires open-url AFTER whenReady which we handle above)
// buffer the URL for when mainWindow loads.
// route through forwardDeepLinkToRenderer so the URL gets stashed under
// its correct IPC channel (auth-url vs oauth-claim).
const initialDeepLink = extractOpenswarmUrl(process.argv);
if (initialDeepLink) pendingDeepLink = initialDeepLink;
if (initialDeepLink) forwardDeepLinkToRenderer(initialDeepLink);
if (process.platform === 'darwin' && !isPackaged) {
try { app.dock.setIcon(iconPath); } catch (_) {}
+8
View File
@@ -73,6 +73,14 @@ const { contextBridge, ipcRenderer } = require('electron');
return () => ipcRenderer.removeListener('openswarm:auth-url', listener);
},
// OAuth claim deep-link channel. Receives openswarm://oauth/{provider}/complete
// after the user finishes an OAuth flow in their browser.
onOauthClaim: (cb) => {
const listener = (_event, url) => cb(url);
ipcRenderer.on('openswarm:oauth-claim', listener);
return () => ipcRenderer.removeListener('openswarm:oauth-claim', listener);
},
// OAuth popup callback. Fires when any child webContents navigates to
// localhost:20128/callback?code=... — main.js watches for this and
// forwards the parsed params here. Used as a belt-and-suspenders
@@ -42,6 +42,7 @@ import ToolCallBubble, { ToolPair } from './ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput, { ChatInputHandle } from './ChatInput';
import { ErrorSlime } from '@/app/components/ErrorSlime';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import DiffViewer from './DiffViewer';
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
@@ -142,6 +143,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
// Used by the "too many connected apps for Haiku" warning rendered above
// ChatInput. Each connected MCP adds a meaningful chunk of tool-schema
// tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them.
const toolItems = useAppSelector((state) => state.tools.items);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
@@ -1196,6 +1201,50 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
</Box>
)}
{(() => {
// Proactive Haiku-overflow warning. Each connected MCP adds
// a sizeable tools-schema chunk to every Claude request;
// Haiku 4.5's window is 5x smaller than Sonnet/Opus, so 5+
// simultaneously-enabled MCPs reliably push a one-line
// message past the limit. We surface this BEFORE the user
// sends so they don't waste a turn on "Prompt is too long".
const isHaiku = (model || '').toLowerCase().startsWith('haiku');
const enabledMcpCount = Object.values(toolItems).filter(
(t) => t.enabled && t.mcp_config && Object.keys(t.mcp_config).length > 0,
).length;
if (!isHaiku || enabledMcpCount < 5) return null;
return (
<Box
sx={{
mx: 2,
mb: 1,
p: 1.5,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${c.status.warning}40`,
bgcolor: `${c.status.warning}10`,
display: 'flex',
alignItems: 'flex-start',
gap: 1.2,
}}
>
<Box sx={{ flexShrink: 0, mt: 0.2 }}>
<ErrorSlime size={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary, mb: 0.4 }}>
Haiku may run out of room with {enabledMcpCount} apps connected
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.45 }}>
Haiku is the fastest Claude model but holds the least at once.
Each connected app adds instructions Claude has to read first.
If your message fails with Prompt is too long, turn off a few
apps (Microsoft 365 is the heaviest) or switch to Sonnet/Opus
both have 5× more room.
</Typography>
</Box>
</Box>
);
})()}
<ChatInput
ref={chatInputRef}
onSend={handleSend}
@@ -66,7 +66,7 @@ const StreamingCursor: React.FC = () => {
const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
interface OpenSwarmErrorInfo {
kind: 'cap' | 'auth' | 'network';
kind: 'cap' | 'auth' | 'network' | 'too_many_tools';
title: string;
detail: string;
ctaLabel?: string;
@@ -106,6 +106,25 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
detail: 'That request timed out after a few retries. Send the message again to continue.',
};
}
// Too many MCP tool definitions for the chosen model's input window.
// Classic case: user has 5+ apps connected (M365 alone has 141 actions),
// chose Haiku (200K context), and even a one-line message can't fit
// because the tool schemas alone push past the limit. Bigger models
// (Sonnet/Opus, 1M) absorb it fine.
if (/Prompt is too long|prompt_too_long|input length and `max_tokens`|context length/i.test(text)) {
return {
kind: 'too_many_tools',
title: 'Too many connected apps for this model',
detail:
"Haiku is fast but has the smallest memory of the three Claude models. " +
"Each connected app adds instructions Claude has to read before it can answer, " +
"and you've added more than Haiku can hold in one go. Either turn off a few apps " +
"(Microsoft 365 is the heaviest by far), or switch to Sonnet or Opus — both have " +
"5× more room.",
ctaLabel: 'Open Settings',
ctaAction: 'settings',
};
}
// Auth / subscription problems
if (/No active subscription|Subscription canceled|Subscription past_due|Invalid.*token|Missing bearer token/i.test(text)) {
return {
+2 -2
View File
@@ -1368,7 +1368,7 @@ const Tools: React.FC = () => {
{uninstalledIntegrations.map((ig) => {
const isLoading = !!integrationLoading[ig.id];
return (
<Card key={ig.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<Card key={ig.id} sx={{ order: 2, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{
@@ -1566,7 +1566,7 @@ const Tools: React.FC = () => {
const isDisabled = tool.enabled === false;
return (
<Card key={tool.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<Card key={tool.id} sx={{ order: tool.auth_status === 'connected' ? 0 : 1, bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2, cursor: isDisabled ? 'default' : 'pointer' }}
+52 -3
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { activateSubscription } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
@@ -15,9 +17,11 @@ export function useDeepLink(): void {
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
if (!api?.onAuthUrl) return;
// Both listeners are optional — useDeepLink no-ops in browser/web context
// where window.openswarm is undefined.
if (!api) return;
const unsubscribe = api.onAuthUrl((rawUrl: string) => {
const unsubscribe = api.onAuthUrl?.((rawUrl: string) => {
try {
// openswarm://auth?token=... (host = "auth", search carries fields)
const url = new URL(rawUrl);
@@ -62,6 +66,51 @@ export function useDeepLink(): void {
}
});
return unsubscribe;
// OAuth claim deep-link listener. The Electron main process routes
// openswarm://oauth/{provider}/complete to its own IPC channel so we
// can claim tokens immediately rather than routing through Settings.
let unsubscribeOauth: (() => void) | undefined;
if (api?.onOauthClaim) {
unsubscribeOauth = api.onOauthClaim(async (rawUrl: string) => {
try {
const url = new URL(rawUrl);
// Expected: openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) {
console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl);
return;
}
const sessionId = url.searchParams.get('session_id');
const toolId = url.searchParams.get('tool_id');
if (!sessionId || !toolId) {
console.warn('[deep-link] Missing session_id or tool_id in', rawUrl);
return;
}
trackEvent('oauth.deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
const resp = await fetch(`${API_BASE}/tools/oauth/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, tool_id: toolId }),
});
if (!resp.ok) {
const text = await resp.text();
console.error('[deep-link] OAuth claim failed:', resp.status, text);
trackEvent('oauth.claim_failed', { status: resp.status });
return;
}
trackEvent('oauth.claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
console.error('[deep-link] OAuth claim threw:', e);
}
});
}
return () => {
unsubscribe?.();
unsubscribeOauth?.();
};
}, [dispatch]);
}
+2
View File
@@ -48,6 +48,8 @@ declare global {
openExternal: (url: string) => Promise<void>;
// Deep-link listener — fires when OS opens the app with openswarm://... URL.
onAuthUrl?: (cb: (url: string) => void) => () => void;
// OAuth claim deep-link listener — fires for openswarm://oauth/{provider}/complete.
onOauthClaim?: (cb: (url: string) => void) => () => void;
}
interface Window {
File diff suppressed because one or more lines are too long
+19 -27
View File
@@ -299,35 +299,27 @@ Copy-Excluded `
(Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') `
@{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc','.env','.env.*') }
# Generate a SAFE production .env containing ONLY OAuth provider credentials.
# Drops APPLE_*/GH_TOKEN/BACKEND_PORT/etc. so signing keys + personal tokens
# never ship to users. Per-user API keys (Anthropic/OpenAI/Gemini) come from
# the in-app Settings UI. Backend's tools_lib.py:34 auto-loads this file.
$ShipEnvKeys = @(
'GOOGLE_OAUTH_CLIENT_ID','GOOGLE_OAUTH_CLIENT_SECRET',
'NOTION_OAUTH_CLIENT_ID','NOTION_OAUTH_CLIENT_SECRET',
'AIRTABLE_OAUTH_CLIENT_ID','AIRTABLE_OAUTH_CLIENT_SECRET',
'HUBSPOT_OAUTH_CLIENT_ID','HUBSPOT_OAUTH_CLIENT_SECRET',
'DISCORD_OAUTH_CLIENT_ID','DISCORD_OAUTH_CLIENT_SECRET',
'DISCORD_BOT_TOKEN'
)
$DevEnvPath = Join-Path $ProjectRoot 'backend\.env'
$ShipEnvPath = Join-Path $Staging 'backend\.env'
if (Test-Path $DevEnvPath) {
$kept = Get-Content $DevEnvPath | Where-Object {
$line = $_
$keep = $false
foreach ($k in $ShipEnvKeys) {
if ($line -match "^$k=") { $keep = $true; break }
}
$keep
}
Set-Content -Path $ShipEnvPath -Value $kept
Write-Host "Staged OAuth credentials: $($kept.Count) keys (release secrets + personal API keys excluded)"
# Production .env: only the OAuth helper base URL + local Google credentials.
$ShipOauthBaseUrl = if ($env:OPENSWARM_OAUTH_BASE_URL_OVERRIDE) {
$env:OPENSWARM_OAUTH_BASE_URL_OVERRIDE
} else {
Write-Host "WARNING: backend\.env not found -- packaged build will have no OAuth credentials configured."
Set-Content -Path $ShipEnvPath -Value '' -NoNewline
'https://api.openswarm.com'
}
$GoogleClientIdShip = $env:GOOGLE_OAUTH_CLIENT_ID
$GoogleClientSecretShip = $env:GOOGLE_OAUTH_CLIENT_SECRET
if (-not $GoogleClientIdShip -or -not $GoogleClientSecretShip) {
Write-Host "ERROR: GOOGLE_OAUTH_CLIENT_ID/SECRET missing in backend\.env -- required for Google MCP." -ForegroundColor Red
exit 1
}
$ShipEnvPath = Join-Path $Staging 'backend\.env'
New-Item -ItemType Directory -Force -Path (Split-Path $ShipEnvPath -Parent) | Out-Null
@(
"# OAuth helper base URL + local Google OAuth credentials.",
"OPENSWARM_OAUTH_BASE_URL=$ShipOauthBaseUrl",
"GOOGLE_OAUTH_CLIENT_ID=$GoogleClientIdShip",
"GOOGLE_OAUTH_CLIENT_SECRET=$GoogleClientSecretShip"
) | Set-Content -Path $ShipEnvPath
Write-Host "Staged production .env: OPENSWARM_OAUTH_BASE_URL + Google client_id/secret"
New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null
Copy-Excluded `
+18 -20
View File
@@ -291,27 +291,25 @@ rsync -a \
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
# Generate a SAFE production .env containing ONLY the OAuth provider
# credentials (the "OpenSwarm public OAuth app" identifiers — these are
# the desktop-app pattern where the client secret isn't truly secret).
# We deliberately DROP everything else from the dev .env:
# - APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID (signing creds — must NOT ship)
# - GH_TOKEN (release upload token — must NOT ship)
# - BACKEND_PORT / DISCORD_BOT_PERMISSIONS (dev-only or trivially recomputable)
# Backend's tools_lib.py:34 calls load_dotenv() on this path at startup,
# so the OAuth flows pick these up automatically. Per-user API keys
# (Anthropic, OpenAI, Gemini) come from the user's Settings UI, not from .env.
SHIP_ENV_KEYS='^(GOOGLE_OAUTH_CLIENT_ID|GOOGLE_OAUTH_CLIENT_SECRET|NOTION_OAUTH_CLIENT_ID|NOTION_OAUTH_CLIENT_SECRET|AIRTABLE_OAUTH_CLIENT_ID|AIRTABLE_OAUTH_CLIENT_SECRET|HUBSPOT_OAUTH_CLIENT_ID|HUBSPOT_OAUTH_CLIENT_SECRET|DISCORD_OAUTH_CLIENT_ID|DISCORD_OAUTH_CLIENT_SECRET|DISCORD_BOT_TOKEN)='
if [[ -f "$PROJECT_ROOT/backend/.env" ]]; then
# Only emit lines whose key matches the allow-list. Comment lines and
# blank lines are dropped (they're not needed in the shipped file).
grep -E "$SHIP_ENV_KEYS" "$PROJECT_ROOT/backend/.env" > "$STAGING_DIR/backend/.env" || true
KEY_COUNT=$(wc -l < "$STAGING_DIR/backend/.env" | tr -d ' ')
echo "Staged OAuth credentials: $KEY_COUNT keys (release secrets + personal API keys excluded)"
else
echo "WARNING: backend/.env not found — packaged build will have no OAuth credentials configured."
: > "$STAGING_DIR/backend/.env"
# Production .env: only the OAuth helper base URL + the local Google
# OAuth credentials (Google's standard "public OAuth app" pattern).
# Signing keys, dev-only vars, and provider client_secrets for everything
# else are intentionally not shipped.
SHIP_OAUTH_BASE_URL="${OPENSWARM_OAUTH_BASE_URL_OVERRIDE:-https://api.openswarm.com}"
GOOGLE_CLIENT_ID_SHIP="${GOOGLE_OAUTH_CLIENT_ID:-}"
GOOGLE_CLIENT_SECRET_SHIP="${GOOGLE_OAUTH_CLIENT_SECRET:-}"
if [[ -z "$GOOGLE_CLIENT_ID_SHIP" || -z "$GOOGLE_CLIENT_SECRET_SHIP" ]]; then
echo "ERROR: GOOGLE_OAUTH_CLIENT_ID/SECRET missing in $ENV_FILE — required for Google MCP."
exit 1
fi
mkdir -p "$STAGING_DIR/backend"
cat > "$STAGING_DIR/backend/.env" <<EOF
# OAuth helper base URL + local Google OAuth credentials.
OPENSWARM_OAUTH_BASE_URL=${SHIP_OAUTH_BASE_URL}
GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_CLIENT_ID_SHIP}
GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET_SHIP}
EOF
echo "Staged production .env"
# Create empty tools directory so the app has a place to write
mkdir -p "$STAGING_DIR/backend/data/tools"