#!/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 from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT from .platforms import facebook, instagram, threads, tiktok, youtube from .queue import PostQueue 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, } # 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": {}}, }, ] # --------------------------------------------------------------------------- # 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 HANDLERS = { "create_post": tool_create_post, "check_login": tool_check_login, "get_status": tool_get_status, } # --------------------------------------------------------------------------- # 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