[eric] mcp: reddit session-borrow MCP (free, full human action set; WIP)

This commit is contained in:
ciregenz
2026-06-29 23:05:15 -07:00
parent eb0b0ba661
commit 63c32554ae
18 changed files with 1038 additions and 4 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "Reddit",
"title": "Reddit",
"description": "Browse subreddits, search posts, analyze users; when the task involves public Reddit content.",
"description": "Browse, search, post, comment, vote, save, subscribe, and DM on Reddit from the user's own logged-in session; for reading or acting on Reddit.",
},
{
"id": "YouTube",
+5
View File
@@ -0,0 +1,5 @@
"""Module-level entrypoint so `python -m backend.apps.reddit_mcp_shim` works."""
from backend.apps.reddit_mcp_shim.server import main
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
"""Dispatch each MCP tool call to the Reddit client and format MCP content."""
import json
from backend.apps.reddit_mcp_shim import reddit_reads as reads
from backend.apps.reddit_mcp_shim import reddit_writes as writes
from backend.apps.reddit_mcp_shim.reddit_http import RedditError
from backend.apps.reddit_mcp_shim.session_source import SessionUnavailable
def mcp_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 mcp_err(text: str) -> dict:
return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True}
def handle_tool_call(name: str, args: dict) -> dict:
try:
return mcp_ok(p_dispatch(name, args))
except SessionUnavailable as e:
return mcp_err(str(e))
except RedditError as e:
return mcp_err(str(e))
except Exception as e:
return mcp_err(f"reddit shim error: {e!r}")
def p_dispatch(name: str, a: dict):
if name == "reddit_whoami":
return reads.whoami()
if name == "reddit_browse":
return reads.browse(a.get("subreddit", ""), a.get("sort", "hot"), a.get("time", ""),
p_lim(a.get("limit"), 25), a.get("after", ""))
if name == "reddit_search":
return reads.search(a.get("query", ""), a.get("subreddit", ""), a.get("sort", "relevance"),
a.get("time", "all"), p_lim(a.get("limit"), 25))
if name == "reddit_get_post":
return reads.get_post(a.get("target", ""), p_lim(a.get("comment_limit"), 50))
if name == "reddit_get_user":
return reads.get_user(a.get("username", ""), a.get("kind", "overview"), p_lim(a.get("limit"), 25))
if name == "reddit_inbox":
return reads.inbox(a.get("where", "inbox"), p_lim(a.get("limit"), 25))
if name == "reddit_my_subreddits":
return reads.my_subreddits(p_lim(a.get("limit"), 100))
if name == "reddit_saved":
return reads.saved(a.get("username", ""), p_lim(a.get("limit"), 25))
if name == "reddit_submit":
return writes.submit(a.get("subreddit", ""), a.get("title", ""), a.get("kind", "self"),
a.get("text", ""), a.get("url", ""), bool(a.get("nsfw")),
bool(a.get("spoiler")), a.get("send_replies", True))
if name == "reddit_comment":
return writes.comment(a.get("parent_id", ""), a.get("text", ""))
if name == "reddit_edit":
return writes.edit(a.get("thing_id", ""), a.get("text", ""))
if name == "reddit_delete":
return writes.delete(a.get("thing_id", ""))
if name == "reddit_vote":
return writes.vote(a.get("thing_id", ""), a.get("direction", ""))
if name == "reddit_save":
return writes.save(a.get("thing_id", ""), bool(a.get("unsave")))
if name == "reddit_subscribe":
return writes.subscribe(a.get("subreddit", ""), bool(a.get("unsubscribe")))
if name == "reddit_send_message":
return writes.compose(a.get("to", ""), a.get("subject", ""), a.get("text", ""))
raise RedditError(f"Unknown tool: {name}")
def p_lim(v, default: int) -> int:
try:
return max(1, min(int(v), 100))
except (TypeError, ValueError):
return default
@@ -0,0 +1,78 @@
"""Built-in spam/rate guards so the shim paces itself like a human.
Two layers: a global minimum gap between any two requests (with jitter), and
per-action token buckets that cap bursty writes (vote/comment/submit/compose).
It also honors Reddit's X-Ratelimit-* response headers and backs off on 429.
Local, per-process; the whole point is to never look like a bot hammering.
"""
import random
import threading
import time
# action -> (bucket_capacity, seconds_to_refill_one_token). Reads are generous; writes are deliberately slow.
BUCKETS: dict[str, tuple[float, float]] = {
"read": (30.0, 1.0),
"vote": (10.0, 3.0),
"comment": (5.0, 12.0),
"submit": (3.0, 60.0),
"compose": (3.0, 30.0),
"subscribe": (10.0, 3.0),
"save": (15.0, 2.0),
}
GLOBAL_MIN_GAP_S = 0.8
GLOBAL_JITTER_S = 0.6
p_lock = threading.Lock()
p_tokens: dict[str, tuple[float, float]] = {}
p_last_request_ts = 0.0
p_backoff_until = 0.0
def bucket_for(action: str) -> str:
return action if action in BUCKETS else "read"
def acquire(action: str) -> None:
"""Block until it's polite to make a request of this action class."""
global p_last_request_ts
bucket = bucket_for(action)
cap, refill = BUCKETS[bucket]
while True:
with p_lock:
now = time.time()
tokens, last = p_tokens.get(bucket, (cap, now))
tokens = min(cap, tokens + (now - last) / refill)
wait = max(0.0, p_backoff_until - now, (p_last_request_ts + GLOBAL_MIN_GAP_S) - now)
if wait <= 0 and tokens >= 1.0:
p_tokens[bucket] = (tokens - 1.0, now)
p_last_request_ts = now
break
if tokens < 1.0:
wait = max(wait, (1.0 - tokens) * refill)
p_tokens[bucket] = (tokens, now)
time.sleep(min(wait, 5.0) + random.uniform(0.0, GLOBAL_JITTER_S))
def note_response(status: int, headers: dict) -> None:
"""Feed response signals back: a 429 or a drained X-Ratelimit means back off."""
global p_backoff_until
retry_after = 0.0
if status == 429:
retry_after = p_to_float(headers.get("retry-after")) or 5.0
remaining = p_to_float(headers.get("x-ratelimit-remaining"))
reset = p_to_float(headers.get("x-ratelimit-reset"))
if remaining is not None and remaining <= 1.0 and reset:
retry_after = max(retry_after, reset)
if retry_after > 0:
with p_lock:
p_backoff_until = max(p_backoff_until, time.time() + retry_after)
def p_to_float(v) -> float | None:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
+113
View File
@@ -0,0 +1,113 @@
"""Low-level authed Reddit transport.
Borrow the user's session, harvest the bearer token their own logged-in web
client already uses (no API key, no app registration), and call the documented
oauth.reddit.com surface. Rate-limited and self-refreshing on token expiry.
stdlib-only to match the sibling shims and start fast.
"""
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from backend.apps.reddit_mcp_shim import rate_limit
from backend.apps.reddit_mcp_shim.session_source import get_session, invalidate
DOMAIN = "reddit.com"
WWW = "https://www.reddit.com"
OAUTH = "https://oauth.reddit.com"
TOKEN_RE = re.compile(r'"accessToken":\s*"([^"]+)"')
EXPIRES_RE = re.compile(r'"(?:expiresIn|expires)":\s*"?(\d+)')
p_token = ""
p_token_exp = 0.0
class RedditError(Exception):
"""A Reddit request failed in a way worth surfacing to the agent."""
def bearer(force: bool = False) -> str:
"""Return a valid bearer token, harvesting a fresh one from authed HTML when stale."""
global p_token, p_token_exp
now = time.time()
if not force and p_token and now < p_token_exp - 60:
return p_token
cookie, ua = get_session(DOMAIN)
req = urllib.request.Request(
f"{WWW}/",
headers={"Cookie": cookie, "User-Agent": ua, "Accept": "text/html"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=20.0) as resp:
text = resp.read().decode("utf-8", errors="replace")
except urllib.error.URLError as e:
raise RedditError(f"Reddit unreachable: {getattr(e, 'reason', e)}")
m = TOKEN_RE.search(text)
if not m:
invalidate(DOMAIN)
raise RedditError(
"Could not read a Reddit session token. Open reddit.com in the OpenSwarm browser, sign in, then retry."
)
p_token = m.group(1)
exp = EXPIRES_RE.search(text)
ttl = float(exp.group(1)) if exp else 3600.0
# The web client reports expiry in ms; fold that down and clamp to a sane window.
if ttl > 86400:
ttl = ttl / 1000.0
p_token_exp = now + min(max(ttl, 300.0), 86400.0)
return p_token
def api(
method: str,
path: str,
*,
params: dict | None = None,
form: dict | None = None,
action: str = "read",
) -> dict:
"""Authenticated oauth.reddit.com call with rate-limiting + one auto token refresh."""
return p_api(method, path, params=params, form=form, action=action, retried=False)
def p_api(method, path, *, params, form, action, retried) -> dict:
rate_limit.acquire(action)
_, ua = get_session(DOMAIN)
token = bearer()
qs = dict(params or {})
qs.setdefault("raw_json", 1)
url = f"{OAUTH}{path}?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v is not None})
data = urllib.parse.urlencode({k: v for k, v in form.items() if v is not None}).encode() if form is not None else None
headers = {"Authorization": f"Bearer {token}", "User-Agent": ua, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30.0) as resp:
status, raw, rhdr = resp.status, resp.read(), dict(resp.headers)
except urllib.error.HTTPError as e:
status, raw, rhdr = e.code, (e.read() if e.fp else b""), dict(e.headers or {})
except urllib.error.URLError as e:
raise RedditError(f"Reddit unreachable: {getattr(e, 'reason', e)}")
rate_limit.note_response(status, {k.lower(): v for k, v in rhdr.items()})
if status == 401 and not retried:
bearer(force=True)
return p_api(method, path, params=params, form=form, action=action, retried=True)
if status == 429:
raise RedditError("Reddit is rate-limiting this account; slow down and retry shortly.")
if status >= 400:
raise RedditError(f"Reddit HTTP {status}: {raw[:300].decode('utf-8', 'replace')}")
try:
return json.loads(raw.decode("utf-8", errors="replace") or "{}")
except json.JSONDecodeError:
return {"raw": raw.decode("utf-8", errors="replace")}
@@ -0,0 +1,146 @@
"""Read operations over the authed oauth.reddit.com surface.
Returns compact, token-frugal records (truncated bodies) rather than Reddit's
raw firehose, so the agent sees what a human skims, not megabytes of JSON.
"""
import re
from backend.apps.reddit_mcp_shim.reddit_http import api
BODY_CAP = 2000
def p_trunc(s: str | None) -> str:
s = s or ""
return s if len(s) <= BODY_CAP else s[:BODY_CAP] + f"... [+{len(s) - BODY_CAP} chars]"
def p_post(d: dict) -> dict:
return {
"id": d.get("name"),
"subreddit": d.get("subreddit"),
"author": d.get("author"),
"title": d.get("title"),
"score": d.get("score"),
"upvote_ratio": d.get("upvote_ratio"),
"num_comments": d.get("num_comments"),
"permalink": d.get("permalink"),
"url": d.get("url"),
"is_self": d.get("is_self"),
"selftext": p_trunc(d.get("selftext")),
"over_18": d.get("over_18"),
"flair": d.get("link_flair_text"),
"created_utc": d.get("created_utc"),
}
def p_comment(d: dict) -> dict:
return {
"id": d.get("name"),
"author": d.get("author"),
"body": p_trunc(d.get("body")),
"score": d.get("score"),
"permalink": d.get("permalink"),
"created_utc": d.get("created_utc"),
}
def p_listing(resp: dict) -> dict:
data = (resp or {}).get("data", {})
items = []
for ch in data.get("children", []):
kind, cd = ch.get("kind"), ch.get("data", {})
items.append(p_comment(cd) if kind == "t1" else p_post(cd))
return {"items": items, "after": data.get("after")}
def whoami() -> dict:
me = api("GET", "/api/v1/me")
return {
"name": me.get("name"),
"id": me.get("id"),
"total_karma": me.get("total_karma"),
"link_karma": me.get("link_karma"),
"comment_karma": me.get("comment_karma"),
"has_mail": me.get("has_mail"),
"created_utc": me.get("created_utc"),
}
def browse(subreddit: str, sort: str, t: str, limit: int, after: str) -> dict:
sort = sort if sort in ("hot", "new", "top", "rising", "best", "controversial") else "hot"
path = f"/r/{subreddit}/{sort}" if subreddit else f"/{sort}"
return p_listing(api("GET", path, params={"limit": limit, "t": t or None, "after": after or None}))
def search(query: str, subreddit: str, sort: str, t: str, limit: int) -> dict:
params = {"q": query, "limit": limit, "sort": sort or "relevance", "t": t or "all"}
if subreddit:
params["restrict_sr"] = 1
path = f"/r/{subreddit}/search"
else:
path = "/search"
return p_listing(api("GET", path, params=params))
def get_post(target: str, comment_limit: int) -> dict:
article = target.split("t3_")[-1]
m = re.search(r"comments/([a-z0-9]+)", target)
if m:
article = m.group(1)
resp = api("GET", f"/comments/{article}", params={"limit": comment_limit, "depth": 6})
post, comments = {}, {"items": []}
if isinstance(resp, list) and len(resp) == 2:
kids = resp[0].get("data", {}).get("children", [])
if kids:
post = p_post(kids[0].get("data", {}))
comments = p_listing(resp[1])
return {"post": post, "comments": comments["items"]}
def get_user(username: str, kind: str, limit: int) -> dict:
about = api("GET", f"/user/{username}/about").get("data", {})
where = kind if kind in ("submitted", "comments", "overview") else "overview"
feed = p_listing(api("GET", f"/user/{username}/{where}", params={"limit": limit}))
return {
"name": about.get("name"),
"link_karma": about.get("link_karma"),
"comment_karma": about.get("comment_karma"),
"created_utc": about.get("created_utc"),
"is_mod": about.get("is_mod"),
"items": feed["items"],
}
def inbox(where: str, limit: int) -> dict:
where = where if where in ("inbox", "unread", "sent", "messages", "mentions") else "inbox"
resp = api("GET", f"/message/{where}", params={"limit": limit})
data = (resp or {}).get("data", {})
msgs = []
for ch in data.get("children", []):
cd = ch.get("data", {})
msgs.append({
"id": cd.get("name"),
"author": cd.get("author"),
"subject": cd.get("subject"),
"body": p_trunc(cd.get("body")),
"new": cd.get("new"),
"context": cd.get("context"),
"created_utc": cd.get("created_utc"),
})
return {"messages": msgs, "after": data.get("after")}
def my_subreddits(limit: int) -> dict:
resp = api("GET", "/subreddits/mine/subscriber", params={"limit": limit})
subs = [
{"name": ch.get("data", {}).get("display_name"), "subscribers": ch.get("data", {}).get("subscribers")}
for ch in (resp or {}).get("data", {}).get("children", [])
]
return {"subreddits": subs}
def saved(username: str, limit: int) -> dict:
user = username or whoami().get("name") or ""
return p_listing(api("GET", f"/user/{user}/saved", params={"limit": limit}))
@@ -0,0 +1,78 @@
"""Write operations: the things a logged-in human does on Reddit.
Posts, comments, edits, deletes, votes, saves, subscriptions, and DMs, all via
the user's own session. Each call goes through the rate limiter's write buckets.
"""
from backend.apps.reddit_mcp_shim.reddit_http import RedditError, api
def p_check(resp: dict) -> dict:
"""Raise on Reddit's json.errors envelope; return the inner data otherwise."""
j = (resp or {}).get("json", resp or {})
errors = j.get("errors") if isinstance(j, dict) else None
if errors:
raise RedditError("; ".join(" ".join(str(p) for p in e) for e in errors))
return j.get("data", {}) if isinstance(j, dict) else {}
def p_dir(direction: str) -> int:
return {"up": 1, "upvote": 1, "down": -1, "downvote": -1, "clear": 0, "none": 0, "unvote": 0}.get(
(direction or "").lower(), 0
)
def submit(subreddit: str, title: str, kind: str, text: str, url: str, nsfw: bool, spoiler: bool, send_replies: bool) -> dict:
form = {
"sr": subreddit,
"title": title,
"kind": "self" if kind != "link" else "link",
"nsfw": "true" if nsfw else "false",
"spoiler": "true" if spoiler else "false",
"sendreplies": "true" if send_replies else "false",
"resubmit": "true",
"api_type": "json",
}
form["url" if kind == "link" else "text"] = url if kind == "link" else text
data = p_check(api("POST", "/api/submit", form=form, action="submit"))
return {"id": data.get("name") or data.get("id"), "url": data.get("url")}
def comment(parent_id: str, text: str) -> dict:
data = p_check(api("POST", "/api/comment", form={"thing_id": parent_id, "text": text, "api_type": "json"}, action="comment"))
things = data.get("things", [])
new = things[0].get("data", {}) if things else {}
return {"id": new.get("name"), "permalink": new.get("permalink")}
def edit(thing_id: str, text: str) -> dict:
data = p_check(api("POST", "/api/editusertext", form={"thing_id": thing_id, "text": text, "api_type": "json"}, action="comment"))
things = data.get("things", [])
new = things[0].get("data", {}) if things else {}
return {"id": new.get("name") or thing_id, "edited": True}
def delete(thing_id: str) -> dict:
api("POST", "/api/del", form={"id": thing_id}, action="save")
return {"id": thing_id, "deleted": True}
def vote(thing_id: str, direction: str) -> dict:
d = p_dir(direction)
api("POST", "/api/vote", form={"id": thing_id, "dir": d}, action="vote")
return {"id": thing_id, "dir": d}
def save(thing_id: str, unsave: bool) -> dict:
api("POST", "/api/unsave" if unsave else "/api/save", form={"id": thing_id}, action="save")
return {"id": thing_id, "saved": not unsave}
def subscribe(subreddit: str, unsubscribe: bool) -> dict:
api("POST", "/api/subscribe", form={"sr_name": subreddit, "action": "unsub" if unsubscribe else "sub"}, action="subscribe")
return {"subreddit": subreddit, "subscribed": not unsubscribe}
def compose(to: str, subject: str, text: str) -> dict:
p_check(api("POST", "/api/compose", form={"to": to, "subject": subject, "text": text, "api_type": "json"}, action="compose"))
return {"to": to, "sent": True}
+62
View File
@@ -0,0 +1,62 @@
"""Stdio JSON-RPC MCP server for Reddit.
Mirrors the discord_mcp_shim loop: stdlib-only, no backend.config imports, so the
subprocess starts fast. The tool surface lives in tools.py; dispatch in handlers.py.
"""
import json
import sys
from backend.apps.reddit_mcp_shim.handlers import handle_tool_call, mcp_err
from backend.apps.reddit_mcp_shim.tools import TOOLS
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-reddit", "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_, mcp_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()
@@ -0,0 +1,78 @@
"""Borrow the user's live browser session (cookies + UA) for a domain.
The shim never stores credentials. It asks the backend's browser-session bridge
(gated by the same per-install token every OpenSwarm shim uses) for the cookies
the user's own logged-in browser already holds in the persist:openswarm-browser
partition, then talks to the site as that browser. stdlib-only so the subprocess
starts fast, matching the sibling shims.
"""
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
AUTH_TOKEN = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BRIDGE_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser-session/cookies"
CACHE_TTL_S = 60.0
# Fallback only; the bridge returns the real spoofed Chrome UA the webview uses.
DEFAULT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
p_cache: dict[str, tuple[float, str, str]] = {}
class SessionUnavailable(Exception):
"""No live logged-in session could be borrowed for the domain."""
def get_session(domain: str) -> tuple[str, str]:
"""Return (cookie_header, user_agent) for domain from the live browser session.
Raises SessionUnavailable with a human-actionable message when the bridge is
unreachable or the user isn't logged in (no cookies for the domain).
"""
now = time.time()
hit = p_cache.get(domain)
if hit and now - hit[0] < CACHE_TTL_S:
return hit[1], hit[2]
url = BRIDGE_URL + "?" + urllib.parse.urlencode({"domain": domain})
headers = {"Accept": "application/json"}
if AUTH_TOKEN:
headers["Authorization"] = f"Bearer {AUTH_TOKEN}"
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=20.0) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
except urllib.error.HTTPError as e:
raise SessionUnavailable(f"Session bridge error (HTTP {e.code}); is the OpenSwarm dashboard open?")
except urllib.error.URLError as e:
raise SessionUnavailable(f"Session bridge unreachable: {getattr(e, 'reason', e)}")
except Exception as e:
raise SessionUnavailable(f"Session bridge request failed: {e!r}")
if data.get("error"):
raise SessionUnavailable(str(data["error"]))
cookies = data.get("cookies") or []
if not cookies:
raise SessionUnavailable(
f"Not logged in to {domain}. Open {domain} in the OpenSwarm browser, sign in, then retry."
)
cookie_header = "; ".join(
f"{c['name']}={c['value']}" for c in cookies if c.get("name")
)
user_agent = data.get("userAgent") or DEFAULT_UA
p_cache[domain] = (now, cookie_header, user_agent)
return cookie_header, user_agent
def invalidate(domain: str) -> None:
"""Drop the cached session so the next call re-borrows fresh cookies."""
p_cache.pop(domain, None)
+194
View File
@@ -0,0 +1,194 @@
"""MCP tool surface for Reddit: the full set of things a logged-in human can do.
Reads (browse/search/post/user/inbox/saved) and writes (submit/comment/edit/
delete/vote/save/subscribe/DM). thing ids are Reddit fullnames (t3_=post,
t1_=comment, t4_=message) exactly as returned by the read tools.
"""
OBJ = "object"
TOOLS = [
{
"name": "reddit_whoami",
"description": "Confirm the logged-in Reddit session and return the account name + karma. Use first to verify the session is live.",
"inputSchema": {"type": OBJ, "properties": {}},
},
{
"name": "reddit_browse",
"description": "List posts from a subreddit (or the logged-in home feed if subreddit is omitted).",
"inputSchema": {
"type": OBJ,
"properties": {
"subreddit": {"type": "string", "description": "e.g. 'programming'. Omit for your home feed."},
"sort": {"type": "string", "enum": ["hot", "new", "top", "rising", "best", "controversial"], "default": "hot"},
"time": {"type": "string", "enum": ["hour", "day", "week", "month", "year", "all"], "description": "For top/controversial."},
"limit": {"type": "integer", "default": 25, "description": "1-100"},
"after": {"type": "string", "description": "Pagination cursor from a previous call."},
},
},
},
{
"name": "reddit_search",
"description": "Search posts globally or within a subreddit.",
"inputSchema": {
"type": OBJ,
"properties": {
"query": {"type": "string"},
"subreddit": {"type": "string", "description": "Restrict to this subreddit."},
"sort": {"type": "string", "enum": ["relevance", "hot", "top", "new", "comments"], "default": "relevance"},
"time": {"type": "string", "enum": ["hour", "day", "week", "month", "year", "all"], "default": "all"},
"limit": {"type": "integer", "default": 25},
},
"required": ["query"],
},
},
{
"name": "reddit_get_post",
"description": "Get a post plus its comment tree. target is a fullname (t3_...), a bare id, or a permalink.",
"inputSchema": {
"type": OBJ,
"properties": {
"target": {"type": "string"},
"comment_limit": {"type": "integer", "default": 50},
},
"required": ["target"],
},
},
{
"name": "reddit_get_user",
"description": "Get a user's profile (karma, age) plus their recent posts/comments.",
"inputSchema": {
"type": OBJ,
"properties": {
"username": {"type": "string"},
"kind": {"type": "string", "enum": ["overview", "submitted", "comments"], "default": "overview"},
"limit": {"type": "integer", "default": 25},
},
"required": ["username"],
},
},
{
"name": "reddit_inbox",
"description": "Read your inbox: messages, replies, mentions, or just unread.",
"inputSchema": {
"type": OBJ,
"properties": {
"where": {"type": "string", "enum": ["inbox", "unread", "sent", "messages", "mentions"], "default": "inbox"},
"limit": {"type": "integer", "default": 25},
},
},
},
{
"name": "reddit_my_subreddits",
"description": "List the subreddits you're subscribed to.",
"inputSchema": {"type": OBJ, "properties": {"limit": {"type": "integer", "default": 100}}},
},
{
"name": "reddit_saved",
"description": "List your saved posts and comments (defaults to the logged-in user).",
"inputSchema": {
"type": OBJ,
"properties": {
"username": {"type": "string"},
"limit": {"type": "integer", "default": 25},
},
},
},
{
"name": "reddit_submit",
"description": "Submit a new post to a subreddit. kind 'self' uses text; kind 'link' uses url.",
"inputSchema": {
"type": OBJ,
"properties": {
"subreddit": {"type": "string"},
"title": {"type": "string"},
"kind": {"type": "string", "enum": ["self", "link"], "default": "self"},
"text": {"type": "string", "description": "Markdown body for a self post."},
"url": {"type": "string", "description": "URL for a link post."},
"nsfw": {"type": "boolean", "default": False},
"spoiler": {"type": "boolean", "default": False},
"send_replies": {"type": "boolean", "default": True},
},
"required": ["subreddit", "title"],
},
},
{
"name": "reddit_comment",
"description": "Reply to a post or comment. parent_id is a fullname (t3_... for a post, t1_... for a comment, t4_... to reply to a message).",
"inputSchema": {
"type": OBJ,
"properties": {
"parent_id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["parent_id", "text"],
},
},
{
"name": "reddit_edit",
"description": "Edit the text of your own post or comment by fullname.",
"inputSchema": {
"type": OBJ,
"properties": {"thing_id": {"type": "string"}, "text": {"type": "string"}},
"required": ["thing_id", "text"],
},
},
{
"name": "reddit_delete",
"description": "Delete your own post or comment by fullname.",
"inputSchema": {
"type": OBJ,
"properties": {"thing_id": {"type": "string"}},
"required": ["thing_id"],
},
},
{
"name": "reddit_vote",
"description": "Vote on a post or comment. direction: up, down, or clear.",
"inputSchema": {
"type": OBJ,
"properties": {
"thing_id": {"type": "string"},
"direction": {"type": "string", "enum": ["up", "down", "clear"]},
},
"required": ["thing_id", "direction"],
},
},
{
"name": "reddit_save",
"description": "Save (or unsave) a post or comment by fullname.",
"inputSchema": {
"type": OBJ,
"properties": {
"thing_id": {"type": "string"},
"unsave": {"type": "boolean", "default": False},
},
"required": ["thing_id"],
},
},
{
"name": "reddit_subscribe",
"description": "Subscribe to (or unsubscribe from) a subreddit by name.",
"inputSchema": {
"type": OBJ,
"properties": {
"subreddit": {"type": "string"},
"unsubscribe": {"type": "boolean", "default": False},
},
"required": ["subreddit"],
},
},
{
"name": "reddit_send_message",
"description": "Send a direct message to a user (to = username, no u/ prefix).",
"inputSchema": {
"type": OBJ,
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"text": {"type": "string"},
},
"required": ["to", "subject", "text"],
},
},
]
+10
View File
@@ -151,6 +151,16 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
env["PYTHONPATH"] = (p_project_root + os.pathsep + existing_pp) if existing_pp else p_project_root
# Reddit MCP runs as a Python shim (backend.apps.reddit_mcp_shim) that borrows the user's live browser session via the backend's cookie bridge, so it needs the localhost port + auth token, plus PYTHONPATH to import itself.
if tool.name.lower() == "reddit" and config.get("type") == "stdio":
from backend.auth import get_auth_token
env = config.setdefault("env", {})
env["OPENSWARM_PORT"] = os.environ.get("OPENSWARM_PORT", "8324")
env["OPENSWARM_AUTH_TOKEN"] = get_auth_token()
p_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"] = (p_project_root + os.pathsep + existing_pp) if existing_pp else p_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":
env = config.setdefault("env", {})
+22
View File
@@ -495,6 +495,28 @@ async def browser_agent_run(request: Request):
return JSONResponse({"results": results})
# Allowlisted social platforms whose own-session MCP shims may borrow partition cookies. The allowlist is the real scope: even an authenticated localhost caller can only ever read these sites' cookies, never an arbitrary domain, so this can't become a general cookie-theft oracle.
P_SESSION_COOKIE_DOMAINS = {"reddit.com", "x.com", "twitter.com", "tiktok.com"}
@app.get("/api/browser-session/cookies")
async def browser_session_cookies(domain: str = ""):
"""Hand a vetted platform's live partition cookies + UA to its own-session MCP shim.
Auth is the standard localhost token (middleware). Cookies are read live from
Electron's persist:openswarm-browser partition via the dashboard bridge and are
never persisted server-side; the shim talks to the site as the user's browser.
"""
d = (domain or "").lower().strip().lstrip(".")
if d not in P_SESSION_COOKIE_DOMAINS:
return JSONResponse({"error": f"domain not allowed: {d or '(empty)'}", "cookies": []}, status_code=400)
rid = uuid4().hex
result = await ws_manager.send_browser_command(rid, "get_session_cookies", "", {"domain": d})
if result.get("error"):
return JSONResponse({"error": result["error"], "cookies": []})
return JSONResponse({"cookies": result.get("cookies", []), "userAgent": result.get("userAgent", "")})
@app.post("/api/mcp-meta/{action}")
async def mcp_meta(action: str, request: Request):
"""Back the openswarm-mcp-meta stdio MCP server.
+130
View File
@@ -0,0 +1,130 @@
"""Unit coverage for the Reddit MCP shim's pure logic: the session-token harvest
regex, the rate limiter's 429 backoff, and tool dispatch + response normalizers
with the network mocked. Writes can't be live-verified without a logged-in
session, so the oauth.reddit.com contract is pinned here against canned Reddit
payloads (the json.errors envelope, the comment/submit shapes) instead."""
import json
import time
from unittest.mock import patch
from backend.apps.reddit_mcp_shim import rate_limit, reddit_reads, reddit_writes
from backend.apps.reddit_mcp_shim.handlers import handle_tool_call
from backend.apps.reddit_mcp_shim.reddit_http import EXPIRES_RE, TOKEN_RE
from backend.apps.reddit_mcp_shim.session_source import SessionUnavailable
def p_text(result: dict) -> str:
return result["content"][0]["text"]
# -- session-token harvest -------------------------------------------------
def test_token_regex_harvests_bearer_from_html():
html = '<script>window.___r={"session":{"accessToken":"eyJabc.def","expiresIn":"86400000"}}</script>'
m = TOKEN_RE.search(html)
assert m and m.group(1) == "eyJabc.def"
e = EXPIRES_RE.search(html)
assert e and e.group(1) == "86400000"
def test_token_regex_absent_when_logged_out():
assert TOKEN_RE.search("<html>login wall, no token here</html>") is None
# -- rate limiter ----------------------------------------------------------
def test_first_read_is_prompt():
start = time.time()
rate_limit.acquire("read")
assert time.time() - start < 1.0
def test_429_backoff_delays_next_request():
rate_limit.note_response(429, {"retry-after": "1"})
start = time.time()
rate_limit.acquire("read")
assert time.time() - start >= 0.8
# -- dispatch + normalizers (network mocked) -------------------------------
def test_browse_normalizes_listing():
listing = {"data": {"after": "t3_next", "children": [
{"kind": "t3", "data": {"name": "t3_a", "subreddit": "python", "author": "u1",
"title": "Hello", "score": 42, "num_comments": 5,
"permalink": "/r/python/comments/a/", "selftext": "body"}},
]}}
with patch.object(reddit_reads, "api", return_value=listing):
out = handle_tool_call("reddit_browse", {"subreddit": "python", "limit": 5})
data = json.loads(p_text(out))
assert "isError" not in out
assert data["after"] == "t3_next"
assert data["items"][0]["id"] == "t3_a"
assert data["items"][0]["title"] == "Hello"
def test_long_selftext_truncated():
listing = {"data": {"children": [{"kind": "t3", "data": {"name": "t3_a", "selftext": "x" * 5000}}]}}
with patch.object(reddit_reads, "api", return_value=listing):
out = handle_tool_call("reddit_browse", {})
body = json.loads(p_text(out))["items"][0]["selftext"]
assert len(body) < 5000 and "+3000 chars" in body
def test_get_post_splits_post_and_comments():
arr = [
{"data": {"children": [{"kind": "t3", "data": {"name": "t3_a", "title": "Q"}}]}},
{"data": {"children": [{"kind": "t1", "data": {"name": "t1_c", "body": "A"}}]}},
]
with patch.object(reddit_reads, "api", return_value=arr):
out = handle_tool_call("reddit_get_post", {"target": "https://www.reddit.com/r/x/comments/a/title/"})
data = json.loads(p_text(out))
assert data["post"]["id"] == "t3_a"
assert data["comments"][0]["id"] == "t1_c"
def test_vote_maps_direction():
captured: dict = {}
def fake_api(method, path, *, params=None, form=None, action="read"):
captured["form"], captured["action"] = form, action
return {}
with patch.object(reddit_writes, "api", fake_api):
out = handle_tool_call("reddit_vote", {"thing_id": "t3_x", "direction": "down"})
assert captured["form"]["dir"] == -1
assert captured["action"] == "vote"
assert json.loads(p_text(out))["dir"] == -1
def test_comment_parses_new_thing():
resp = {"json": {"errors": [], "data": {"things": [
{"kind": "t1", "data": {"name": "t1_new", "permalink": "/r/x/comments/a/_/t1_new/"}}]}}}
with patch.object(reddit_writes, "api", return_value=resp):
out = handle_tool_call("reddit_comment", {"parent_id": "t3_a", "text": "nice"})
assert json.loads(p_text(out))["id"] == "t1_new"
def test_submit_surfaces_reddit_errors():
envelope = {"json": {"errors": [["SUBREDDIT_NOEXIST", "that subreddit doesn't exist", "sr"]], "data": {}}}
with patch.object(reddit_writes, "api", return_value=envelope):
out = handle_tool_call("reddit_submit", {"subreddit": "nope", "title": "hi"})
assert out.get("isError") is True
assert "doesn't exist" in p_text(out)
def test_session_unavailable_is_actionable():
def boom(*a, **k):
raise SessionUnavailable("Not logged in to reddit.com. Open reddit.com in the OpenSwarm browser, sign in, then retry.")
with patch.object(reddit_reads, "api", boom):
out = handle_tool_call("reddit_whoami", {})
assert out.get("isError") is True
assert "logged in" in p_text(out).lower()
def test_unknown_tool_errors():
out = handle_tool_call("reddit_nonsense", {})
assert out.get("isError") is True
+21
View File
@@ -2776,6 +2776,27 @@ ipcMain.handle('browser:clear-data', async () => {
return { ok: true };
});
// Hand the user's own logged-in cookies for a vetted social platform to its session-backed MCP shim (Reddit/X/TikTok). Reads from the browser partition's main-process cookie store, so httpOnly auth cookies (e.g. reddit_session) are included, which document.cookie can't see. Allowlisted domains ONLY, so this can never become a general cookie-theft surface; the backend re-checks the same allowlist before it ever calls this.
const SESSION_COOKIE_DOMAINS = ['reddit.com', 'x.com', 'twitter.com', 'tiktok.com'];
ipcMain.handle('get-partition-cookies', async (_e, domain) => {
const d = String(domain || '').toLowerCase().trim().replace(/^\./, '');
if (!SESSION_COOKIE_DOMAINS.includes(d)) {
return { cookies: [], userAgent: '', error: `domain not allowed: ${d || '(empty)'}` };
}
try {
const ses = session.fromPartition(BROWSER_PARTITION);
const raw = await ses.cookies.get({ domain: d });
const cookies = raw.map((c) => ({ name: c.name, value: c.value }));
// Match the partition's spoofed Chrome UA so the shim's requests are byte-identical to the webview's.
const userAgent = process.platform === 'win32'
? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
return { cookies, userAgent };
} catch (err) {
return { cookies: [], userAgent: '', error: `cookie read failed: ${err && err.message}` };
}
});
ipcMain.handle('get-update-status', () => cachedUpdateStatus);
// One-shot recovery info: if the crash-watchdog relaunched us, returns the
+2
View File
@@ -60,6 +60,8 @@ contextBridge.exposeInMainWorld('openswarm', {
// Clears cookies/cache/localStorage for the browser-card partition only (never the app's defaultSession). Logs you out of sites opened in browser cards.
clearBrowserData: () => ipcRenderer.invoke('browser:clear-data'),
connectSlack: () => ipcRenderer.invoke('connect-slack'),
// Hands a vetted social platform's partition cookies to its session-backed MCP shim (allowlisted domains only, gated again in the main process).
getPartitionCookies: (domain) => ipcRenderer.invoke('get-partition-cookies', domain),
sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId),
cdpDetachClean: (wcId) => ipcRenderer.invoke('cdp-detach-clean', wcId),
cdpCacheSet: (wcId, indexMap) => ipcRenderer.invoke('cdp-cache-set', wcId, indexMap),
@@ -25,10 +25,11 @@ export const INTEGRATIONS: Integration[] = [
{
id: 'reddit',
name: 'Reddit',
description: 'Browse subreddits, search posts, get post details, analyze users. No API keys required.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'reddit-mcp-buddy'] },
description: 'Browse, search, post, comment, vote, save, subscribe, and DM, all from your own logged-in Reddit session. No API key.',
mcp_config: { type: 'stdio', command: 'python', args: ['-m', 'backend.apps.reddit_mcp_shim'] },
color: '#FF4500',
website: 'https://www.npmjs.com/package/reddit-mcp-buddy',
website: 'https://www.reddit.com',
connectInstructions: 'Uses your own Reddit account: open reddit.com in an OpenSwarm browser card and sign in once. Nothing is stored, the integration borrows your live session per request and paces itself to stay within human limits.',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="12" fill="#FF4500"/>
@@ -1396,10 +1396,28 @@ async function handleBrowserCommand(data: Record<string, any>) {
}
}
// Hand a vetted social platform's partition cookies to its session-backed MCP shim. No webview needed: it reads the main-process cookie store directly, so it runs before the webview lookup.
async function handleSessionCookies(params: Record<string, any>): Promise<Record<string, any>> {
const bridge = (window as any).openswarm?.getPartitionCookies as
| ((domain: string) => Promise<{ cookies: { name: string; value: string }[]; userAgent: string; error?: string }>)
| undefined;
if (!bridge) return { error: 'Cookie bridge unavailable (desktop app only)', cookies: [] };
try {
return await bridge(String(params.domain || ''));
} catch (err: any) {
return { error: `Cookie bridge failed: ${err?.message || String(err)}`, cookies: [] };
}
}
async function runBrowserCommand(
request_id: string, action: string, browser_id: string, tab_id: string | undefined,
params: Record<string, any>,
) {
if (action === 'get_session_cookies') {
const result = await handleSessionCookies(params);
dashboardWs.send('browser:result', { request_id, ...result });
return;
}
const wv = await awaitWebview(browser_id, tab_id || undefined);
if (!wv) {
dashboardWs.send('browser:result', {