"""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 p_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 p_err(text: str) -> dict: return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True} def p_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 p_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 = p_call("GET", "/users/@me/guilds") if status != 200: return p_err(f"Discord proxy unreachable (HTTP {status}): {body}") return p_ok({"connected": True, "guilds": body}) if name == "discord_get_server_info": gid = str(args.get("guild_id", "")) if (e := p_check_guild(gid)): return p_err(e) status, body = p_call("GET", f"/guilds/{gid}") return p_ok(body) if status == 200 else p_err(f"HTTP {status}: {body}") if name == "discord_list_channels": gid = str(args.get("guild_id", "")) if (e := p_check_guild(gid)): return p_err(e) status, body = p_call("GET", f"/guilds/{gid}/channels") return p_ok(body) if status == 200 else p_err(f"HTTP {status}: {body}") if name == "discord_create_text_channel": gid = str(args.get("guild_id", "")) if (e := p_check_guild(gid)): return p_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 = p_call("POST", f"/guilds/{gid}/channels", body=payload) return p_ok(body) if status in (200, 201) else p_err(f"HTTP {status}: {body}") if name == "discord_create_category": gid = str(args.get("guild_id", "")) if (e := p_check_guild(gid)): return p_err(e) status, body = p_call("POST", f"/guilds/{gid}/channels", body={"name": args.get("name", ""), "type": 4}) return p_ok(body) if status in (200, 201) else p_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 = p_call("PATCH", f"/channels/{cid}", body=payload) return p_ok(body) if status == 200 else p_err(f"HTTP {status}: {body}") if name == "discord_delete_category" or name == "discord_delete_channel": cid = str(args.get("channel_id", "")) status, body = p_call("DELETE", f"/channels/{cid}") return p_ok({"deleted": True}) if status in (200, 204) else p_err(f"HTTP {status}: {body}") if name == "discord_send": cid = str(args.get("channel_id", "")) content = str(args.get("content", "")) status, body = p_call("POST", f"/channels/{cid}/messages", body={"content": content}) return p_ok(body) if status in (200, 201) else p_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 = p_call("GET", f"/channels/{cid}/messages", query={"limit": limit}) return p_ok(body) if status == 200 else p_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 = p_call("PUT", f"/channels/{cid}/messages/{mid}/reactions/{urllib.parse.quote(emoji, safe='')}/@me") return p_ok({"added": emoji}) if status in (200, 204) else p_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 = p_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 p_ok({"reactions": results}) if name == "discord_get_forum_channels": gid = str(args.get("guild_id", "")) if (e := p_check_guild(gid)): return p_err(e) status, body = p_call("GET", f"/guilds/{gid}/channels") if status != 200: return p_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 p_ok(forums) if name == "discord_create_forum_post": fid = str(args.get("forum_id", "")) status, body = p_call("POST", f"/channels/{fid}/threads", body={ "name": args.get("name", ""), "message": {"content": args.get("content", "")}, }) return p_ok(body) if status in (200, 201) else p_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 = p_call("GET", f"/channels/{cid}/messages/{mid}") return p_ok(body) if status == 200 else p_err(f"HTTP {status}: {body}") if name == "discord_reply_to_forum": cid = str(args.get("channel_id", "")) content = str(args.get("content", "")) status, body = p_call("POST", f"/channels/{cid}/messages", body={"content": content}) return p_ok(body) if status in (200, 201) else p_err(f"HTTP {status}: {body}") return p_err(f"Unknown tool: {name}") # -- JSON-RPC stdio loop --------------------------------------------------- def p_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": p_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": p_send(id_, {"tools": TOOLS}) elif method == "tools/call": name = params.get("name", "") args = params.get("arguments", {}) or {} try: p_send(id_, handle_tool_call(name, args)) except Exception as e: p_send(id_, p_err(f"shim crashed: {e!r}")) elif method == "ping": p_send(id_, {}) elif id_ is not None: p_send(id_, error={"code": -32601, "message": f"Method not found: {method}"}) if __name__ == "__main__": main()