352 lines
11 KiB
Python
352 lines
11 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
|
|
|
|
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
|