Files
openswarm/backend/apps/discord_mcp_shim/server.py
T

456 lines
17 KiB
Python

"""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()