mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] mcp: reddit session-borrow MCP (free, full human action set; WIP)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user