- dashboard.html: local dashboard.json mode (10s poll) alongside artifact db mode; clickable cards behind an http(s)-only safeUrl filter; meta charset; mode-aware freshness line - openbridge.py: localhost shim exposing open_link so dashboard clicks reuse the platform's warm tab instead of spawning one per click - cdp.py: Tab.activate(); get_or_open_tab activates reused tabs — a backgrounded tab never finishes rendering, which made YouTube's login check read a signed-in account as signed out - README/MANIFEST: corrected CDP port (9336, was documented as 9222), removed the false claim that the server writes dashboard.json, documented the reboot/VPS requirements - .gitignore: __pycache__/, *.pyc, state.json, Archived/; removed stale tracked pycache and state.json from index
7.9 KiB
socialmag — Social Media MCP Server
One tool, all platforms. Post to Threads, Facebook, Instagram, TikTok, Twitter/X (and YouTube when eligible) through a single MCP call. Built on CDP — no API keys, no rate limits, no approval process. Just a browser and a logged-in account.
What It Does
Write
create_post— Post text/media to one or more platforms in one callreply_tweet— Reply to a tweet found by search
Read
check_login— Verify which platforms are logged inget_status— Last post results + open browser tabsget_feed— Twitter timeline or search, with X operators (from:,min_faves:, …)get_notifications— Platform activity (Threads)
Navigate
open_link— Send a platform's existing tab to a URL, instead of spawning a new one
Architecture
Claude/MCP Client dashboard.html (browser)
↓ stdio (JSON-RPC) ↓ fetch (localhost)
socialmag server.py ←────────────── openbridge.py :8322
↓ CDP WebSocket
Brave Browser (dedicated instance, port 9336)
↓ real browser sessions
Threads · Facebook · Instagram · TikTok · YouTube · Twitter/X
- Tab reuse — attaches to existing tabs, no cold loads
- Tab activation — a reused tab is brought to the front, because a backgrounded tab is not guaranteed to have rendered (see Journey)
- Per-platform modules — each platform has its own flow + selectors
- Sequential queue — one platform at a time, no race conditions
- Error screenshots — failed flows capture the page state to
/tmp/socialmag/
Platform Status
| Platform | Text | Media | Read | Status |
|---|---|---|---|---|
| Threads | ✅ | 🔲 | ✅ | Live — inline composer + activity scraping |
| ✅ | 🔲 | — | Live — dialog flow | |
| — | ✅ | — | Live — media required | |
| TikTok | — | ✅ | — | Live — video/photo upload |
| Twitter/X | ✅ | 🔲 | ✅ | Live — post, reply, timeline + search |
| YouTube | 🔲 | 🔲 | — | Parked — needs 500 subs |
✅ = tested live · 🔲 = coded but untested · — = not applicable
Stack
- Python 3 (stdlib + websockets)
- Chrome DevTools Protocol (CDP)
- MCP stdio transport
- Brave Browser with
--remote-debugging-port
Browser Setup
# Dedicated Brave instance (separate profile, no interference)
brave --remote-debugging-port=9336 \
--user-data-dir=$HOME/.local/share/brave-socialmag \
--disable-features=BeforeUnloadDialog \
--mute-audio --no-first-run
Log into each platform manually once. socialmag reuses those sessions.
Journey So Far
v0.1 — 2026-09-10 · it posts
Five platforms coded, four live. Threads, Facebook, Instagram, TikTok posting through a real logged-in browser. YouTube parked behind its 500-subscriber gate. First automated post from the house went out that night.
v0.2 — 2026-09-11 (early hours) · it reads
Twitter/X joined as a full module — post, reply, timeline, search.
get_notifications landed for Threads. open_link built for warm-tab reuse.
Pushed to a private GitHub repo.
What the read side cost that the plan didn't predict:
- The Threads scraper was believed to already return a post
link. It did not — it returned{text}only. Row detection had to be built from the live DOM. The rule that worked: a row is an element that links somewhere, carries a relative-time marker, and contains no smaller element that does the same. - A tweet's permalink is not "the first
/status/href" — an article carries three (canonical,/analytics,/photo/N). It's the anchor wrapping<time>.
v0.3 — 2026-09-11 (morning) · it shows, and it stops lying
Dashboard, two modes. Artifact mode reads four live db collections over
onSnapshot. Local mode falls back to dashboard.json beside the page, polling
every 10s, served off any plain HTTP server.
Clicks stopped exploding. openbridge.py exposes open_link over localhost so
a dashboard card navigates the platform's existing tab. Verified: 8 tabs before two
clicks, 8 after. It imports tool_open_link rather than reimplementing it.
The YouTube false-negative. check_login reported YouTube signed out while the
account was signed in. Not a race, not an account problem — a backgrounded tab
never finishes rendering. YouTube leaves its masthead as
#masthead-skeleton-icons with an empty #buttons, so #avatar-btn — the element
the check looks for — never mounts. Measured: still absent after 23 seconds, while
ytcfg.get('LOGGED_IN') was true the whole time. Fixed by activating a reused tab
(Target.activateTarget): yt now returns True 3/3, against False 3/3 before.
The general lesson, written down because it will bite again: a DOM-render signal needs the foreground; a JS-state signal does not.
What was lost. The v2 dashboard files were written to /tmp/socialmag/ and did
not survive a /tmp clear. Git held only the pre-v2 version. They were rebuilt from
the field handback. Everything since is written into the repo.
Roadmap
Done
get_notifications(platform)— Threads activity via CDP scraping (v0.2)get_feed— Twitter timeline + search (v0.2)reply_tweet(v0.2)- Web dashboard — one view for all platform activity (v0.3)
open_link+ openbridge — tab reuse from the dashboard (v0.3)
Next — monitoring
- A writer for
dashboard.json— nothing writes it today; it is hand-maintained - Periodic refresh (systemd timer over cron: journal + ordering +
Persistent=true) - Units so
:8321and:8322survive reboot - Unified notification feed across all platforms
get_comments,get_engagement
⚠️ The blocker isn't the code. Interval refresh now steals browser focus by design, since tab activation is what makes login checks truthful. And Brave is not a service — after boot there is no CDP until it's opened by hand, so a refresher must treat "no browser" as a waiting state, not a failure. See README → Surviving reboot.
Later
- Post scheduler + content calendar
- Analytics — engagement trends, best posting times
- Media pipeline — Threads/Facebook media, YouTube video upload, per-platform resize
- Multi-account support
- VPS deployment — a different product, not a port. Needs a headless browser with all six accounts logged in there, a display (unrendered pages give wrong answers — see the YouTube finding), and it puts real accounts at risk of being challenged from a datacentre IP
- AI-assisted caption generation
File Structure
socialmag/
├── __init__.py # package marker
├── __main__.py # python -m socialmag entry
├── server.py # MCP stdio server + 7 tool handlers
├── cdp.py # CDP WebSocket client (CDPClient + Tab, incl. activate)
├── selectors.py # all platform selectors in one file
├── queue.py # sequential post queue
├── openbridge.py # localhost HTTP shim — open_link for the dashboard
├── dashboard.html # status/timeline viewer (artifact db or local JSON)
├── dashboard.json # local-mode data — hand-maintained, nothing writes it
├── platforms/
│ ├── __init__.py # shared helpers (guard, require_media, etc.)
│ ├── threads.py
│ ├── facebook.py
│ ├── instagram.py
│ ├── tiktok.py
│ ├── twitter.py
│ └── youtube.py
├── README.md # how to run it, and what it costs to keep running
└── MANIFEST.md # this file
License
Private. Shared among collaborators by invitation.
Born 2026-09-10. Built by the household — Celeste architected, Umbra built the skeleton, abang drove the FAFO.