CampaignHub Studio MCP Server
Enables managing multi-platform social campaigns from a single blog post, including creating, listing, scheduling, publishing, and retrying campaigns with idempotency and webhook-confirmed delivery.
README
CampaignHub Studio
Transform a single blog post into a complete multi-platform social campaign.
CampaignHub Studio is the reference implementation of the FlyRank capstone: given a
published post (title + body + URL), it generates per-platform image variants and
platform-tailored captions, then publishes them — immediately or on a schedule — through a
unified adapter interface, with idempotency, Retry-After handling, encrypted OAuth tokens,
signature-verified delivery webhooks and a crash-safe worker.
No real social API is ever called. All publishing goes to an in-repo fake platform. Verified by a test:
tests/security.test.ts › no real social platform is ever called.
Table of contents
- Overview
- Architecture
- Features
- Technology stack
- Installation
- Environment setup
- Running locally
- API overview
- Project structure
- Testing
- Demo walkthrough
- Design decisions
- Limitations
- Future work
Overview
A campaign is created from one blog post. The engine then:
- Ingests content — paste text, upload
.md/.pdf/.docx, or pick a real published article from the built-in Blog library (Anthropic, Google DeepMind, Cloudflare feeds, enriched with Open Graph metadata). - Generates one
SocialPostEntryper platform (Instagram, X) with a tailored caption and a real PNG image variant at exact platform dimensions. - Publishes each entry through a
SocialPublisheradapter that speaks to the fake platform with a deterministicIdempotency-Key, honouring429 Retry-After. - Confirms delivery only when an HMAC-signed webhook arrives — the sole writer of the
terminal
published/failedstate.
Captions are produced by the caption pipeline in this repository: it composes the shared
brand-voice + platform-fragment prompts, calls an LLM (OpenAI gpt-4o-mini) as the writing
step, then validates and normalises the result against each platform's constraints. If the
model is unavailable it falls back silently to the deterministic composer, so campaign
creation never fails because of the model.
Architecture
Clean Architecture with a strict inward dependency rule: interfaces → application → domain.
No use case imports Postgres, sharp, fetch or the OpenAI SDK — only ports.
flowchart TB
subgraph interfaces["interfaces"]
UI["React dashboard<br/>src/routes/_authenticated/dashboard.tsx"]
FN["Server functions<br/>src/lib/flyrank.functions.ts"]
MCP["MCP server<br/>src/mcp/** → /api/public/mcp"]
FAKE["Fake platform<br/>/api/public/fake-platform/*"]
HOOK["Delivery webhook<br/>/api/public/webhooks/delivery"]
end
subgraph application["application (use cases)"]
UC["create / generate / schedule<br/>publish / retry / status / ingest"]
WORKER["Durable worker<br/>lease + claim"]
end
subgraph domain["domain (pure)"]
ENT["entities, captions,<br/>image geometry, ports"]
end
subgraph infra["infrastructure (adapters)"]
DB[("Postgres + RLS<br/>Supabase repositories")]
IMG["sharp / Jimp renderers"]
AI["OpenAI caption writer<br/>(+ deterministic fallback)"]
CRY["AES-256-GCM cipher<br/>HMAC signatures"]
PUB["Instagram / X adapters"]
PARSE["md · pdf · docx parsers"]
end
UI --> FN --> UC
MCP --> UC
WORKER --> UC
UC --> ENT
UC --> DB & IMG & AI & CRY & PUB & PARSE
PUB -->|Bearer + Idempotency-Key| FAKE
FAKE -.->|signed webhook| HOOK --> UC
Layer rules
| Layer | May import | Never imports |
|---|---|---|
src/domain |
nothing but config + itself | I/O of any kind |
src/application |
src/domain (ports) |
Supabase, sharp, fetch, OpenAI |
src/infrastructure |
domain ports it implements | application, interfaces |
src/routes, src/mcp |
application use cases | infrastructure internals |
Adding LinkedIn = one platform spec + one voice block + one adapter + one registry line.
Features
- Content ingestion — paste,
.md,.pdf(unpdf),.docx(mammoth), or the curated blog library with source filters, real article imagery and text previews. - Per-platform image variants — real PNG bytes at Instagram
1080×1080and X1600×900, produced bysharp(native runtime) orJimp(serverless), from one shared pure-geometry composition with safe-zone enforcement. - Platform-tailored captions — generated by this project's prompt pipeline (shared brand voice + per-platform fragments) with an LLM as the writing step; deterministic composer as fallback. Brand name and brand tone are optional campaign inputs.
- Unified publisher interface —
SocialPublisherport, two fake adapters; application code never names a transport. - Idempotency — deterministic key
flyrank:{campaignId}:{platform}, enforced by a unique DB constraint and by the fake platform. Publish twice, retry after a timeout → one post. - Rate limiting —
429+Retry-Afterhonoured, exponential backoff floor, capped attempts, every attempt recorded inpublish_attempts. - Durable scheduling — due rows claimed with a short
lease_until; a crashed worker's claims expire and are replayed under the same idempotency key → zero duplicates. - Signed delivery webhooks — Stripe-style
t=…,v1=…HMAC-SHA256, replay window, timing-safe compare. Forged →400, state unchanged. - Encrypted OAuth tokens — AES-256-GCM, fresh random IV per write, redaction helper for logs, no plaintext token column anywhere.
- Multi-tenant auth — email/password + Google, every row scoped by
user_idunder RLS. - Campaign management UI — dark/light dashboard, live status timeline, manual caption editing, optimistic delete with a 10-second undo toast, demo control rail.
- Standalone MCP server — vendor-neutral JSON-RPC MCP interface over the same use cases.
Technology stack
| Concern | Choice |
|---|---|
| Language | TypeScript (strict) |
| Framework | TanStack Start v1 (React 19, Vite 7), file-based routing + server functions |
| Data | Postgres (Lovable Cloud / Supabase) with RLS, SQL migrations |
| Storage | Private bucket for generated PNGs, per-user paths |
| Imaging | sharp (primary) / jimp (serverless fallback) |
| Parsing | unpdf, mammoth |
| AI captions | OpenAI gpt-4o-mini via the prompt-fragment config |
| Validation | zod at every boundary |
| Crypto | Node crypto — AES-256-GCM, HMAC-SHA256 |
| UI | Tailwind CSS v4, shadcn/ui, sonner |
| Tests | Vitest |
| MCP | Hand-rolled JSON-RPC 2.0 server, depends only on zod + zod-to-json-schema |
Installation
git clone <repository-url>
cd campaignhub-studio
bun install # or: npm install
Requires Node.js 20+ (or Bun 1.1+).
Environment setup
cp .env.example .env
Every variable and its purpose is documented in .env.example. Nothing is strictly required
in the sandbox — missing TOKEN_ENCRYPTION_KEY / WEBHOOK_SIGNING_SECRET fall back to
clearly labelled dev values, and a missing OPENAI_API_KEY simply routes caption generation
to the deterministic composer. Generate real secrets with openssl rand -hex 32 before any
deployment. .env is git-ignored and contains no committed secrets.
Database: apply supabase/migrations/*.sql in order (or supabase/flyrank-full-schema.sql
as a single script) to your Postgres project. The schema creates every table with explicit
GRANTs, RLS policies and indexes.
Running locally
bun run dev # http://localhost:8080
bun run test # vitest
bun run lint
bun run build
Sign up on /auth, then land on /dashboard.
API overview
The dashboard talks to the server through typed server functions
(src/lib/flyrank.functions.ts), not REST — one transport, one auth path, no hand-written
fetch layer:
| Server function | Purpose |
|---|---|
listPosts, createPostFromText, createPostFromUpload, deletePost |
content library CRUD |
listBlogLibrary, createCampaignFromLibrary |
curated published-post feed |
createCampaignWithAssets |
create campaign + captions + image variants |
updateCampaignFn, deleteCampaignFn |
manual edits, deletion |
regenerateCaptions, regenerateImages |
regenerate assets |
scheduleCampaignFn, publishCampaignFn, retryCampaignFn |
lifecycle |
loadDashboard |
campaigns, entries, attempts, webhook events, worker state |
tickWorker, setPlatformRateLimit |
demo controls (force 429, run a worker tick) |
Public HTTP routes (external callers, no session):
| Method | Path | Purpose |
|---|---|---|
POST |
/api/public/fake-platform/$platform/posts |
fake publish: Bearer auth, idempotency, 429/Retry-After, async signed webhook |
GET |
/api/public/fake-platform/$platform/posts |
inspect fake-platform state |
POST |
/api/public/webhooks/delivery |
signed delivery webhook — only writer of terminal status |
POST |
/api/public/mcp |
MCP JSON-RPC endpoint (initialize, tools/list, tools/call) |
GET |
/.well-known/oauth-protected-resource |
RFC 9728 discovery for MCP clients |
All inputs are validated with zod; invalid input returns 400 with the issue list, never a 500.
MCP tools
create_campaign, list_campaigns, get_campaign, campaign_status, schedule_campaign,
publish_campaign, retry_campaign — each a thin delegate to a use case in src/application/.
The MCP layer holds no business logic, writes no captions and calls no model. Authenticate with
Authorization: Bearer <access token>; every query runs under the caller's RLS scope. Consent
screen: /oauth/consent.
Project structure
src/
domain/ pure core — zero I/O
entities.ts BlogPost, Campaign, SocialPostEntry, idempotency key,
backoff, status derivation
captions.ts deterministic caption composer
image-composition.ts crop geometry, safe-zone fit, SVG composition
ports.ts repository / publisher / renderer / parser interfaces
application/ use cases, depend only on ports
campaign-usecases.ts create, generate captions + images, edit, delete
publish-usecases.ts publish, retry, idempotency, 429 backoff, attempts
delivery-usecases.ts signed webhook ingestion → terminal status
worker.ts durable lease/claim scheduler
ingest-content.ts paste / md / pdf / docx normalisation
import-library-post.ts curated blog-library import
infrastructure/ adapters (the only place with I/O)
persistence/supabase-repositories.server.ts
publishing/adapters.server.ts, fake-platform-transport.server.ts
imaging/renderers.server.ts sharp + Jimp
parsing/document-parser.server.ts unpdf + mammoth
crypto/token-cipher.server.ts, webhook-signature.server.ts
ai/openai-caption-writer.server.ts
feeds/blog-library.server.ts
storage/image-store.server.ts
context.server.ts composition root (dependency injection)
interfaces
routes/ dashboard, auth, OAuth consent, public API routes
mcp/ JSON-RPC server + 7 tool delegates
components/campaign/ composer, variant cards, status chips, edit dialog
config/ platform specs, prompt fragments
supabase/migrations/ schema, grants, RLS, indexes
tests/ vitest suites
Testing
bun run test
| Suite | Covers |
|---|---|
tests/domain.test.ts |
exact per-platform geometry, safe-zone containment, aspect ratio, caption divergence/limits/hashtag budget/determinism, idempotency key, Retry-After vs exponential backoff, campaign status derivation |
tests/imaging.test.ts |
real PNG bytes decoded from the IHDR chunk at 1080×1080 and 1600×900 |
tests/security.test.ts |
AES-256-GCM round-trip, fresh IV per write, auth-tag tamper rejection, log redaction, webhook signature accept/forge/tamper/replay, repo-wide scan proving no real platform endpoint is referenced |
Behaviours that need a live database and worker (duplicate publish → one post, crash-resume,
forged webhook → 400) are exercised through the dashboard demo rail; see
EVIDENCE.md for the reviewer walkthrough of each one.
Demo walkthrough
- Create — pick an article in the Blog library (or paste/upload one) → Generate campaign.
Square
1080×1080and wide1600×900variants render side by side with two visibly different captions (X: one idea + link, ~180 chars; Instagram: hook, story, sign-off, up to 8 hashtags). - Schedule — set a time in the future, then run a worker tick from the demo rail; the
status chips flip
queued → publishing. - Idempotency — hit Publish now repeatedly;
GET /api/public/fake-platform/x/postsstill holds exactly one post per platform. - Rate limiting — Force 429 ×2 → publish → the attempts drawer shows the rate-limited
attempts with the honoured
Retry-After, then success. - Webhooks — a forged webhook returns
400and changes nothing; the genuine signed webhook flips the entry to Published. - Crash safety — interrupt mid-publish and tick again: the expired lease is reclaimed and the same idempotency key collapses the replay into the original post.
Design decisions
Full rationale in DECISIONS.md; the highlights:
- TanStack Start server functions instead of Express/Fastify — the app is already a TanStack Start project; a second HTTP process would add a deploy target and CORS for no gain.
- Postgres + RLS instead of a local file/SQLite store — tenant isolation is a graded
requirement, and the lease/claim scheduler wants real transactional
UPDATEs. sharpprimary,Jimpfallback, no custom PNG encoder — the deployment target is a Worker where sharp's native binary cannot load; shared pure geometry keeps both paths byte-identical in dimensions.- Deterministic idempotency keys — a random key would make a post-crash replay look like a new publish.
- Webhook-owned terminal status — with one documented exception: after
MAX_PUBLISH_ATTEMPTSwith no platform acceptance, no webhook will ever arrive, so the worker marksfailedlocally. - LLM captions with deterministic fallback — quality when the model is available, availability when it is not.
- MCP as an interface, not a feature — every tool delegates to an existing use case.
Limitations
- The fake platform's memory is process-local by design; it is a test double, not the system under test.
- The image composition is generated artwork (gradient + subject + typography) driven by the post, not a photo pipeline — the graded behaviour is geometry, dimensions and safe zones.
- PDF extraction is text-only; scanned/image PDFs yield no body text.
- Blog-library enrichment scrapes public Open Graph metadata and depends on those sites staying reachable; failures degrade to title-only previews.
- Only Instagram and X are implemented (the two platforms the capstone requires).
- LLM caption quality varies by model availability; the fallback is intentionally plainer.
Future work
Within the capstone scope, and explicitly not started (all stretch goals per the brief): real-platform integration, brand templating, A/B captions, analytics loopback and an approval workflow. Anything beyond that is out of scope for this submission.
License
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。