Files
socialmag/server.py
T

579 lines
19 KiB
Python

#!/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