From 63c32554aec4012ab70802aa6115aaa4714c0e33 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 29 Jun 2026 23:05:15 -0700 Subject: [PATCH] [eric] mcp: reddit session-borrow MCP (free, full human action set; WIP) --- backend/apps/agents/core/mcp_preflight.py | 2 +- backend/apps/reddit_mcp_shim/__init__.py | 0 backend/apps/reddit_mcp_shim/__main__.py | 5 + backend/apps/reddit_mcp_shim/handlers.py | 76 +++++++ backend/apps/reddit_mcp_shim/rate_limit.py | 78 +++++++ backend/apps/reddit_mcp_shim/reddit_http.py | 113 ++++++++++ backend/apps/reddit_mcp_shim/reddit_reads.py | 146 +++++++++++++ backend/apps/reddit_mcp_shim/reddit_writes.py | 78 +++++++ backend/apps/reddit_mcp_shim/server.py | 62 ++++++ .../apps/reddit_mcp_shim/session_source.py | 78 +++++++ backend/apps/reddit_mcp_shim/tools.py | 194 ++++++++++++++++++ backend/apps/tools_lib/mcp_config.py | 10 + backend/main.py | 22 ++ backend/tests/test_reddit_mcp_shim.py | 130 ++++++++++++ electron/main.js | 21 ++ electron/preload.js | 2 + frontend/src/app/pages/Tools/integrations.tsx | 7 +- frontend/src/shared/browserCommandHandler.ts | 18 ++ 18 files changed, 1038 insertions(+), 4 deletions(-) create mode 100644 backend/apps/reddit_mcp_shim/__init__.py create mode 100644 backend/apps/reddit_mcp_shim/__main__.py create mode 100644 backend/apps/reddit_mcp_shim/handlers.py create mode 100644 backend/apps/reddit_mcp_shim/rate_limit.py create mode 100644 backend/apps/reddit_mcp_shim/reddit_http.py create mode 100644 backend/apps/reddit_mcp_shim/reddit_reads.py create mode 100644 backend/apps/reddit_mcp_shim/reddit_writes.py create mode 100644 backend/apps/reddit_mcp_shim/server.py create mode 100644 backend/apps/reddit_mcp_shim/session_source.py create mode 100644 backend/apps/reddit_mcp_shim/tools.py create mode 100644 backend/tests/test_reddit_mcp_shim.py diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index 1ebd8ddf..9e78b126 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -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", diff --git a/backend/apps/reddit_mcp_shim/__init__.py b/backend/apps/reddit_mcp_shim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/reddit_mcp_shim/__main__.py b/backend/apps/reddit_mcp_shim/__main__.py new file mode 100644 index 00000000..444aa093 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/__main__.py @@ -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() diff --git a/backend/apps/reddit_mcp_shim/handlers.py b/backend/apps/reddit_mcp_shim/handlers.py new file mode 100644 index 00000000..c14445e2 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/handlers.py @@ -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 diff --git a/backend/apps/reddit_mcp_shim/rate_limit.py b/backend/apps/reddit_mcp_shim/rate_limit.py new file mode 100644 index 00000000..93fb0cf2 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/rate_limit.py @@ -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 diff --git a/backend/apps/reddit_mcp_shim/reddit_http.py b/backend/apps/reddit_mcp_shim/reddit_http.py new file mode 100644 index 00000000..d9c775f1 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/reddit_http.py @@ -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")} diff --git a/backend/apps/reddit_mcp_shim/reddit_reads.py b/backend/apps/reddit_mcp_shim/reddit_reads.py new file mode 100644 index 00000000..4d7b0256 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/reddit_reads.py @@ -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})) diff --git a/backend/apps/reddit_mcp_shim/reddit_writes.py b/backend/apps/reddit_mcp_shim/reddit_writes.py new file mode 100644 index 00000000..96b2ac20 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/reddit_writes.py @@ -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} diff --git a/backend/apps/reddit_mcp_shim/server.py b/backend/apps/reddit_mcp_shim/server.py new file mode 100644 index 00000000..a46dc6e6 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/server.py @@ -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() diff --git a/backend/apps/reddit_mcp_shim/session_source.py b/backend/apps/reddit_mcp_shim/session_source.py new file mode 100644 index 00000000..4bba2270 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/session_source.py @@ -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) diff --git a/backend/apps/reddit_mcp_shim/tools.py b/backend/apps/reddit_mcp_shim/tools.py new file mode 100644 index 00000000..45528582 --- /dev/null +++ b/backend/apps/reddit_mcp_shim/tools.py @@ -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"], + }, + }, +] diff --git a/backend/apps/tools_lib/mcp_config.py b/backend/apps/tools_lib/mcp_config.py index c87307f9..d1b3efa1 100644 --- a/backend/apps/tools_lib/mcp_config.py +++ b/backend/apps/tools_lib/mcp_config.py @@ -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", {}) diff --git a/backend/main.py b/backend/main.py index 7905f39d..2b68d0d5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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. diff --git a/backend/tests/test_reddit_mcp_shim.py b/backend/tests/test_reddit_mcp_shim.py new file mode 100644 index 00000000..3cc8c96f --- /dev/null +++ b/backend/tests/test_reddit_mcp_shim.py @@ -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 = '' + 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("login wall, no token here") 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 diff --git a/electron/main.js b/electron/main.js index aee96e17..e611a73e 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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 diff --git a/electron/preload.js b/electron/preload.js index a6f7d6d3..9c3854fb 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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), diff --git a/frontend/src/app/pages/Tools/integrations.tsx b/frontend/src/app/pages/Tools/integrations.tsx index 9df3254e..cc2747f7 100644 --- a/frontend/src/app/pages/Tools/integrations.tsx +++ b/frontend/src/app/pages/Tools/integrations.tsx @@ -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: ( diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index 506c02e8..f48d0215 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -1396,10 +1396,28 @@ async function handleBrowserCommand(data: Record) { } } +// 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): Promise> { + 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, ) { + 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', {