#!/usr/bin/env python3 """socialmag — MCP stdio server. Three tools: create_post post to one or more platforms through abang's logged-in Brave check_login which platforms are currently signed in get_status the last create_post run's results Transport is the standard MCP stdio pattern: one JSON-RPC message per line on stdin, one per line on stdout. Nothing but protocol goes to stdout — logs go to stderr. Run: python3 -m socialmag (from the parent dir; expects Brave on :9222) NOT `python3 server.py` — selectors.py/queue.py shadow stdlib modules unless this runs as a package. See __init__.py. """ import asyncio import json import os import sys import time import urllib.parse from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT from .platforms import facebook, instagram, threads, tiktok, twitter, youtube from .queue import PostQueue from .selectors import PLATFORMS as PLATFORM_SELECTORS PROTOCOL_VERSION = "2025-06-18" SERVER_NAME = "socialmag" SERVER_VERSION = "0.1.0" STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json") MODULES = { "threads": threads, "fb": facebook, "ig": instagram, "yt": youtube, "tiktok": tiktok, "twitter": twitter, } def _domain_of(platform): """The tab-match string for a platform, derived from its selectors URL — 'https://www.threads.com/' -> 'threads.com'. One source of truth: change a platform's URL in selectors.py and tab matching follows it.""" host = urllib.parse.urlsplit(PLATFORM_SELECTORS[platform]["url"]).hostname if not host: raise RuntimeError( f"selectors.PLATFORMS[{platform!r}]['url'] has no hostname: " f"{PLATFORM_SELECTORS[platform]['url']!r}" ) return host[4:] if host.startswith("www.") else host # threads.com · facebook.com · instagram.com · youtube.com · tiktok.com · x.com PLATFORM_DOMAINS = {p: _domain_of(p) for p in MODULES} # The endpoint is a spec constant, not a fallback for missing config. Override # with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere. CDP_ENDPOINT = os.environ.get("SOCIALMAG_CDP_ENDPOINT", DEFAULT_HTTP_ENDPOINT) def log(*args): print(f"[socialmag {time.strftime('%H:%M:%S')}]", *args, file=sys.stderr, flush=True) # --------------------------------------------------------------------------- # Tool schemas # --------------------------------------------------------------------------- TOOLS = [ { "name": "create_post", "description": ( "Post to one or more social platforms through the logged-in Brave " "browser (CDP). Platforms run sequentially, one tab at a time. " "Returns a per-platform status map." ), "inputSchema": { "type": "object", "properties": { "platforms": { "type": "array", "items": {"enum": sorted(MODULES)}, "description": "Any combination of threads, fb, ig, yt, tiktok.", }, "text": { "type": "string", "description": ( "Post body. Required for threads/fb/yt; used as the " "caption for ig/tiktok." ), }, "media": { "type": "string", "description": ( "Absolute path to an image/video. Optional for " "threads/fb; REQUIRED for ig/tiktok; not supported " "for yt community posts." ), }, "visibility": { "enum": ["public", "friends"], "description": "Facebook audience. Not implemented yet — see detail.", }, }, "required": ["platforms"], }, }, { "name": "check_login", "description": ( "Check which platforms the browser is signed in to. Opens and " "closes one tab per platform; no posting." ), "inputSchema": { "type": "object", "properties": { "platforms": { "type": "array", "items": {"enum": sorted(MODULES)}, "description": "Defaults to all five when omitted.", } }, }, }, { "name": "get_status", "description": "Results of the last create_post run, plus queue history.", "inputSchema": {"type": "object", "properties": {}}, }, { "name": "get_feed", "description": ( "Read tweets from Twitter/X. Two modes: 'following' reads the " "home timeline (Following tab), 'search' scrapes live results " "for a query. Supports X search operators (from:, since:, " "min_faves:, lang:). Returns up to 20 tweets with author, " "text, time, and engagement counts." ), "inputSchema": { "type": "object", "properties": { "mode": { "enum": ["following", "search"], "description": "following = home timeline, search = keyword search.", "default": "following", }, "query": { "type": "string", "description": ( "Search query (required for search mode). Supports " "X operators: from:user, since:2026-01-01, " "min_faves:100, lang:en, etc." ), }, "count": { "type": "integer", "description": "Max tweets to return (1-20, default 10).", "default": 10, }, }, }, }, { "name": "reply_tweet", "description": ( "Reply to a tweet found by search query. Finds the first " "matching tweet and posts a reply to it." ), "inputSchema": { "type": "object", "properties": { "search_query": { "type": "string", "description": ( "Search query to find the tweet to reply to. Use " "from:user + keywords for precision." ), }, "text": { "type": "string", "description": "Reply text.", }, }, "required": ["search_query", "text"], }, }, { "name": "get_notifications", "description": ( "Check notifications/activity from social platforms. Currently " "supports Threads (Activity page). Returns a list of recent " "notifications with text, time, and post links where available." ), "inputSchema": { "type": "object", "properties": { "platform": { "enum": ["threads"], "description": "Platform to check. Currently only threads.", }, "count": { "type": "integer", "description": "Max notifications to return (1-20, default 10).", "default": 10, }, }, "required": ["platform"], }, }, { "name": "open_link", "description": ( "Open a URL in the platform's existing browser tab (reuses warm " "tabs). Falls back to opening a new tab if none exists. The tab is " "left open — that's what makes it reusable next time." ), "inputSchema": { "type": "object", "properties": { "platform": { "enum": sorted(MODULES), "description": ( "Which platform's tab to reuse. Matched by domain: " + ", ".join(f"{k} → {v}" for k, v in sorted(PLATFORM_DOMAINS.items())) ), }, "url": { "type": "string", "description": "Full http(s) URL to navigate to.", }, }, "required": ["platform", "url"], }, }, ] # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- def save_state(state): tmp = STATE_PATH + ".tmp" with open(tmp, "w") as fh: json.dump(state, fh, indent=2) os.replace(tmp, STATE_PATH) def load_state(): if not os.path.exists(STATE_PATH): return {"last_post": None, "results": {}} with open(STATE_PATH) as fh: return json.load(fh) # --------------------------------------------------------------------------- # Tool implementations # --------------------------------------------------------------------------- async def tool_create_post(args, queue): platforms = args.get("platforms") if not platforms: raise ValueError("create_post: 'platforms' is required and must be non-empty") unknown = [p for p in platforms if p not in MODULES] if unknown: raise ValueError( f"create_post: unknown platform(s) {unknown}; known: {sorted(MODULES)}" ) text = args.get("text") media = args.get("media") options = {} if "visibility" in args: options["visibility"] = args["visibility"] results = {} cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() log(f"connected to {cdp.browser_version} at {CDP_ENDPOINT}") try: for name in platforms: module = MODULES[name] def factory(module=module): return module.post(cdp, text, media, options) try: results[name] = await queue.submit(name, factory) except CDPError as exc: # Raised before the flow reached its guard (bad args, bad media). results[name] = {"status": "error", "detail": f"{name}: {exc}"} except Exception as exc: # noqa: BLE001 results[name] = { "status": "error", "detail": f"{name}: unexpected {type(exc).__name__}: {exc}", } log(f"{name} -> {results[name]['status']}") finally: await cdp.close() state = { "last_post": time.strftime("%Y-%m-%dT%H:%M:%S"), "results": results, "text": text, "media": media, } save_state(state) return results async def tool_check_login(args, queue): platforms = args.get("platforms") or sorted(MODULES) unknown = [p for p in platforms if p not in MODULES] if unknown: raise ValueError( f"check_login: unknown platform(s) {unknown}; known: {sorted(MODULES)}" ) out = {} cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() try: for name in platforms: module = MODULES[name] def factory(module=module): return module.check_login(cdp) try: out[name] = bool(await queue.submit(f"check:{name}", factory)) except Exception as exc: # noqa: BLE001 — a failed check is not a False out[name] = f"error: {type(exc).__name__}: {exc}" finally: await cdp.close() return out async def tool_get_status(args, queue): state = load_state() state["queue_history"] = queue.history[-20:] state["queue_depth"] = queue.depth # Show open browser tabs so the caller can see what's reusable try: cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() targets = await cdp.list_targets() state["open_tabs"] = [ {"id": t["targetId"], "url": t.get("url", ""), "title": t.get("title", "")} for t in targets ] await cdp.close() except Exception: state["open_tabs"] = "could not connect to browser" return state async def tool_get_feed(args, queue): mode = args.get("mode", "following") query = args.get("query") count = min(args.get("count", 10), 20) cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() try: result = await twitter.get_feed(cdp, mode=mode, query=query, count=count) finally: await cdp.close() return result async def tool_reply_tweet(args, queue): search_query = args.get("search_query") text = args.get("text") if not search_query or not text: raise ValueError("reply_tweet: both 'search_query' and 'text' are required") cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() try: result = await twitter.reply(cdp, search_query, text) finally: await cdp.close() return result async def tool_get_notifications(args, queue): platform = args.get("platform") count = min(args.get("count", 10), 20) notif_modules = { "threads": threads, } if platform not in notif_modules: raise ValueError( f"get_notifications: unsupported platform {platform!r}; " f"supported: {sorted(notif_modules)}" ) cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() try: result = await notif_modules[platform].get_notifications(cdp, count=count) finally: await cdp.close() return result async def tool_open_link(args, queue): platform = args.get("platform") url = args.get("url") # Validate before touching the browser: a bad call should cost nothing. if platform not in PLATFORM_DOMAINS: raise ValueError( f"open_link: unknown platform {platform!r}; " f"known: {sorted(PLATFORM_DOMAINS)}" ) if not url or not isinstance(url, str): raise ValueError("open_link: 'url' is required and must be a string") scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in ("http", "https"): raise ValueError( f"open_link: url must be http(s), got {scheme or 'no'} scheme: {url!r}" ) domain = PLATFORM_DOMAINS[platform] async def go(): cdp = CDPClient(CDP_ENDPOINT) await cdp.connect() try: tab, reused = await cdp.get_or_open_tab(url, domain) if reused: # A warm tab may hold an unload prompt (a half-written composer); # clear it, then send the tab to the requested URL. await tab.eval("window.onbeforeunload = null") await tab.navigate(url) # Deliberately NOT closed — leaving it open is what makes the next # open_link on this platform a reuse. return { "status": "ok", "tab_reused": reused, "platform": platform, "matched_domain": domain, "url": url, } finally: await cdp.close() # Through the queue like every other browser job: one tab at a time. return await queue.submit(f"open:{platform}", go) HANDLERS = { "create_post": tool_create_post, "check_login": tool_check_login, "get_status": tool_get_status, "get_feed": tool_get_feed, "reply_tweet": tool_reply_tweet, "get_notifications": tool_get_notifications, "open_link": tool_open_link, } # --------------------------------------------------------------------------- # JSON-RPC / MCP plumbing # --------------------------------------------------------------------------- class Server: def __init__(self): self.queue = PostQueue() async def handle(self, msg): """Return a response dict, or None for notifications.""" method = msg.get("method") mid = msg.get("id") params = msg.get("params") or {} if method == "initialize": return self._ok( mid, { "protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}}, "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, }, ) if method in ("notifications/initialized", "initialized"): return None if method == "ping": return self._ok(mid, {}) if method == "tools/list": return self._ok(mid, {"tools": TOOLS}) if method == "tools/call": return await self._call_tool(mid, params) if mid is None: return None # unknown notification — ignore, don't answer return self._err(mid, -32601, f"method not found: {method}") async def _call_tool(self, mid, params): name = params.get("name") args = params.get("arguments") or {} handler = HANDLERS.get(name) if handler is None: return self._err(mid, -32602, f"unknown tool: {name}") try: result = await handler(args, self.queue) except Exception as exc: # noqa: BLE001 — report as a tool error, loudly log(f"tool {name} failed: {type(exc).__name__}: {exc}") return self._ok( mid, { "content": [ {"type": "text", "text": f"{type(exc).__name__}: {exc}"} ], "isError": True, }, ) return self._ok( mid, {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]}, ) @staticmethod def _ok(mid, result): return {"jsonrpc": "2.0", "id": mid, "result": result} @staticmethod def _err(mid, code, message): return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}} async def main(): server = Server() await server.queue.start() reader = asyncio.StreamReader() await asyncio.get_running_loop().connect_read_pipe( lambda: asyncio.StreamReaderProtocol(reader), sys.stdin ) log(f"listening on stdio · CDP endpoint {CDP_ENDPOINT}") while True: line = await reader.readline() if not line: break line = line.strip() if not line: continue try: msg = json.loads(line) except json.JSONDecodeError as exc: log(f"dropping unparseable line: {exc}") continue response = await server.handle(msg) if response is not None: sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() await server.queue.stop() log("stdin closed, exiting") if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass