weone-daily-post
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@weone-daily-postCreate a post about aviation training and publish to socials"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
weone-daily-post — remote MCP server
Publishing backend for the We One Aviation daily Instagram + Facebook post. It is a stateless Streamable HTTP MCP server: Claude does the thinking (topic choice, wording, caption), this service does the side effects (history, image rendering, storage, Graph API). Images are typeset, not generated: the text on the poster is exactly the text supplied.
Claude ──POST /mcp (Bearer)──▶ Render web service (Node 20, Express)
├─ Supabase posts table + post-images bucket
├─ Chromium HTML template → JPEG
└─ Meta Graph IG container/publish, FB photosTools
Tool | Purpose |
| Newest-first history, max 200 rows: |
| Inserts |
| Renders a branded HTML template to an exact-size sRGB JPEG, uploads it, and returns an image block plus the public URL. |
| Publishes to Instagram then Facebook, records the outcome. |
| Shadow mode: record the finished post as |
| Days until the Meta token expires + granted scopes. |
Every tool returns JSON. Success is {"ok": true, ...}; failure is an MCP error
result containing {"ok": false, "error": {code, message, retryable, details}}.
Nothing throws a raw stack trace at the caller.
render_post
template is one of news, subject or career — the same three categories
the posts table uses.
Field | Limit | Notes |
| 60 chars | Barlow 700, up to 3 lines. Sentence case, not title case. |
| 3–4 items, 90 chars each | Barlow 400, one gold marker each |
| 32 chars, optional | Gold, uppercased by CSS, e.g. |
| 90 chars, optional | Left side of the footer bar, e.g. |
It returns two content blocks: an image block (base64 JPEG) and a text block with the public URL, filename, dimensions, byte size and render time.
Two guards run before anything is uploaded, and both name the offending field:
Length limits, checked before Chromium is touched — the cheap rejection.
points[2] is 97 characters, limit is 90. Shorten it and retry.In-page measurement, after layout — every text box is a fixed-size clipping box, and if its content is taller or wider than the box the render is rejected with the field name and the overflow in pixels. This catches what a character count cannot see, such as one unbreakable 80-character token that is legal in length but runs off the edge.
Nothing is uploaded when either guard fires, so a rejection costs a second and the fix is always "shorten the named field".
The image block still comes back so you can read the wording in context, but it is no longer a correctness check: a fixed template cannot misspell a word or invent a diagram. Worst-case legal input (58-char headline plus four 90-char points, widest legal eyebrow and footer) has been verified to fit all three templates.
publish_socials, step by step
HEADtheimage_urland assert200+content-type: image/jpeg. Meta fetches this URL server-side, and a bad URL there fails opaquely hours later. (A store that rejectsHEADgets a one-byte rangedGETinstead.)Instagram —
full_caption = caption + "\n\n" + hashtags.join(' '), capped at 2200 characters. Only hashtags are dropped, from the end; the caption body is never truncated. If the body alone exceeds 2200 the call fails withcaption_too_longbefore anything is posted.POST {IG_USER_ID}/media→ pollGET {container}?fields=status_code,statusonce a second for up to 60s → publish only onFINISHED. OnERRORthestatusstring is returned verbatim, because it is the only place Meta explains what it disliked.Facebook —
POST {FB_PAGE_ID}/photoswithurlandmessage. Attempted regardless of the Instagram outcome.Record — the
postsrow getsig_post_id,fb_post_id,image_url, andstatus=published(both),partial(one) orfailed(neither).
Returns {ig_post_id, fb_post_id, status, errors: [...]}. A single-platform
failure is never swallowed: it appears in errors[] with the platform, the
stage it failed at, and Meta's own code / error_subcode / message.
Related MCP server: Social Analytics MCP Server
Environment variables
Variable | Required | What it is |
| yes | Shared secret for |
| yes |
|
| yes | Service-role key. Bypasses RLS — server-side only. Never put it in the connector config. |
| no (default | Graph API version used for every call. |
| for publishing | Instagram Business account id (a number, not the @handle). |
| for publishing | Facebook Page id linked to that Instagram account. |
| for publishing | Long-lived Page access token with |
| no | Render sets this. Default 10000. |
| no (default | Size above which the inline base64 preview is downscaled. |
| no | Explicit path to a Chrome/Chromium binary. Overrides the per-platform default. |
| no | Set to |
Copy .env.example to .env for local runs. .env is gitignored — keep it that way.
Setup
1. Supabase
Run migrations/001_init.sql in the SQL editor (or supabase db push). It is
idempotent and creates:
the
poststable with the check constraints and the unique index ontopic— that index is the repetition guard, so a duplicate reserve is supposed to fail,created_at descandstatusindexes,RLS enabled on
postswith no policies (only the service key gets in),the public
post-imagesstorage bucket plus its public-read policy. Public read is mandatory: Meta fetches the JPEG itself and cannot present credentials.
2. Meta
You need an Instagram Business or Creator account linked to a Facebook Page,
and a long-lived Page token with the scopes listed above. Confirm it with
check_token() before the first run — an expired token is the single most
common cause of a failed morning.
3. Deploy to Render
With render.yaml (Blueprint):
Push this repo to GitHub.
Render dashboard → New → Blueprint → pick the repo. It reads
render.yaml: Node 20,npm ci && npm run build,npm start, health check on/health.Render prompts for every
sync: falsevariable. Paste them.Deploy, then check the logs for
server.listening ... auth=configured.auth=MISSINGmeansMCP_AUTH_TOKENdid not get set.
Manually:
New → Web Service → connect the repo.
Runtime Node, build
npm ci && npm run build, startnpm start.Health check path
/health.Add the environment variables from the table above, plus
NODE_VERSION=20.
Verify:
curl https://<your-service>.onrender.com/health
# {"status":"ok","server":{...},"tools":[...six...],"uptime_s":3}Use the Starter plan, not free. Chromium needs roughly 400 MB resident on
top of Node, and the free instance is 512 MB — it will OOM mid-render, and the
failure shows up as a dead worker rather than a useful log line. Free also sleeps
after inactivity, so the first tool call of the day pays a 30–60 second cold
start on top. render.yaml sets starter for both reasons.
No browser download is needed at build time: @sparticuz/chromium ships its own
binary as a dependency, so npm ci && npm run build is the whole build. That
build step also copies src/templates/ into dist/ — tsc only emits .ts, so
without it the server starts fine and then fails on the first render with a
missing template file.
4. Connect it to Claude
The endpoint is:
https://<your-service>.onrender.com/mcpwith the header:
Authorization: Bearer <MCP_AUTH_TOKEN>Claude Code / Cowork CLI:
claude mcp add --transport http weone-social \
https://<your-service>.onrender.com/mcp \
--header "Authorization: Bearer <MCP_AUTH_TOKEN>".mcp.json (project-scoped, checked in without the token):
{
"mcpServers": {
"weone-social": {
"type": "http",
"url": "https://<your-service>.onrender.com/mcp",
"headers": { "Authorization": "Bearer ${MCP_AUTH_TOKEN}" }
}
}
}In the Claude desktop/web custom connector dialog, paste the same /mcp URL
and put the bearer token in the request-header field. Authentication is
header-only by design — the token is never accepted as a query parameter,
because URLs end up in proxy logs and browser history.
The daily-run instructions (brand rules, banned claims, category rotation, image
spec, QA checklist) live in the weone-daily-post skill, not in this server.
This service deliberately holds no editorial policy.
Local development
npm install
cp .env.example .env # fill it in
npm run dev # tsx watch, http://localhost:10000
npm run typecheck
npm run build && npm startnpm run smoke
npm run smoke # render all three templates, upload, print 3 URLs
npm run smoke -- --no-upload # render locally only, no credentials neededRenders one of each template, writes all three JPEGs to ./out, uploads them,
HEAD-checks each public URL and prints the three links. It then proves both
guards still fire. It touches no Meta endpoint, so it is safe against
production credentials. Upload needs only SUPABASE_URL and
SUPABASE_SERVICE_KEY; --no-upload needs nothing.
Local files are written before the upload, so a Supabase failure still leaves something to look at.
Rendering pipeline
A headless Chromium loads src/templates/{template}.html over file://, the
values are written into the DOM, and the page is screenshotted. The same input
always produces the same pixels.
Templates live in
src/templates/.tokens.cssholds every colour;base.cssholds the skeleton all three share. A template file differs from its siblings only in the eyebrow treatment and the point marker (news: gold rule, subject: numbered gold circles, career: gold chevrons).Fonts are self-hosted in
src/templates/fonts/(Barlow 400/600/700 for everything, Cinzel 600 for the wordmark alone, latin subsets, OFL). Nothing is fetched at render time — a network call would make the output non-deterministic and would fail silently on Render, falling back to a system serif. The renderer waits ondocument.fonts.readyand then asserts both faces actually loaded rather than screenshotting a fallback.No user text is ever concatenated into markup. Values go in through
textContentandcreateElement, so there is no escaping to get wrong: a<script>in a headline lands on the poster as the literal characters.Viewport 1080×1350 at
deviceScaleFactor: 2, so the screenshot is 2160×2700 and downsampled — text edges stay clean.sharp:
resize(1080, 1350, {fit:'cover'})→toColorspace('srgb')→jpeg({quality: 90, chromaSubsampling: '4:4:4'}), metadata stripped. 4:4:4 is not decoration — 4:2:0 smears coloured text edges, and these posters are text.Asserts the encoded JPEG is under 8 MB and that the decoded dimensions really are what was asked for.
Uploads as
{yyyy-mm-dd}-{slug}-{6 hex}.jpg(UTC date). Every render gets its own key and nothing is ever overwritten —upsert: false. Re-rendering a topic cannot change the picture under a post that already published the previous URL.cacheControlis 60s for the same reason: a bad object stays correctable within a minute instead of being pinned in a CDN for a year. Meta fetches the URL once, server-side, moments after upload, so nothing needs the long cache. Objects accumulate; storage is far cheaper than a stale image on a live post.
Layout behaviour
Type scales to the point count. Three points get a 68px headline and 36px
body; four get 60px and 32px. This is done in CSS with :has(), so the layout
decision lives entirely in the template and the renderer neither knows nor cares.
The overflow guard runs after scaling, so the scaled result is what gets measured.
The content block is vertically centred between the header rule and the footer bar. Fixed gaps alone cannot hold a fill target because the amount of text varies, so three elastic elements share the leftover space: a band above, a band below, and the gap under the headline. The bands are hard-capped at 150px, which is what enforces "no large empty margin"; once they cap, surplus goes into the headline gap where it reads as breathing room rather than a hole.
Measured vertical fill on representative content: 74–79%, bands 99–124px. A deliberately sparse case (one-line headline, three one-line points) sits at 68.8% with the bands at their 150px cap — with a hard cap on the bands that is the arithmetic maximum for that little text, and lifting it further would mean spreading the points so far apart they stop reading as a list.
Headlines are Barlow 700 in sentence case, leading 1.1, letter-spacing
−0.5px. Cinzel survives only in the "WE ONE AVIATION" wordmark. Sentence case is
not enforced in code — mechanically lower-casing a headline would wreck DGCA,
ATPL and AAI — so it is specified in the headline field description
instead.
Each template carries a flat SVG accent inline: a broad diagonal rule (news), concentric compass arcs (subject), a rising chevron stack (career). Gold at 7%, bleeding off the bottom-right behind the text. They exist to give the composition weight at thumbnail size and are far too faint to affect text contrast.
The accent sits inside
.anchor-wrap, a box pinned to the canvas withoverflow: hidden. Without it the absolutely-positioned graphic hangs past the bottom edge, counts towardbody.scrollHeight, and the overflow guard rejects every render with a constant 160px page overflow.
The logo
src/templates/assets/logo.png is the supplied lockup: the star/aircraft mark
above a "WE ONE AVIATION" wordmark. The header renders that wordmark itself in
Cinzel, so scripts/prepare-logo.mjs derives logo-mark.png — the mark alone —
to avoid printing the brand name twice. It finds the horizontal bands of
non-transparent pixels and keeps the tallest, so re-exporting the logo at another
resolution still works. After replacing logo.png:
npm run prepare-logoBrowser lifecycle and memory
One Chromium is shared for the life of the process and relaunched only if it disconnects. Launching costs about a second and a few hundred MB, far too much to repeat per post.
Render memory. Chromium needs roughly 400 MB resident on top of Node. The free instance is 512 MB and will OOM under it — the deploy dies mid-render with no useful log line. Use the Starter plan. If you must stay on free, expect restarts and treat the first render after each one as a cold start.
Where the binary comes from depends on the host:
Host | Source |
| that path, always wins |
Linux (Render) |
|
macOS / dev | whatever |
--single-process is deliberately not used. It is incompatible with reusing
one browser: closing a BrowserContext under that flag tears down the whole
browser, so the second render fails with "Target page, context or browser has
been closed". Measured on this codebase: 1 of 3 contexts survive with it, 3 of 3
without. Reuse is the more valuable half of the trade. Set
CHROMIUM_SINGLE_PROCESS=1 to force it back on if a host ever demands it, and
expect one render per launch.
Error handling
Code | Meaning |
| Arguments failed validation. |
| The topic already exists. Working as designed — pick another. |
| No |
| Supabase said no. |
| Provider or sharp problem. |
| The URL Meta would fetch is not a reachable JPEG. |
| Caption body alone exceeds 2200 chars. Hashtags are trimmed automatically; the body never is. |
| Graph API. |
| Something exceeded its budget (image 60s, container poll 60s, Graph 30s). |
| A required environment variable is missing. |
Meta codes 190 and 200 are never retried. 190 is an expired or invalid
token, 200 is a missing permission; both need a human, and retrying only burns
rate limit while hiding the real cause. Those errors come back with
retryable: false and a needs_human note saying what to do.
Every tool call logs tool.start and tool.ok/tool.error with a duration, and
every Graph call logs graph.call with method, endpoint, status and elapsed ms —
so Render's log viewer is enough to reconstruct a run.
Troubleshooting
Symptom | Cause |
| Header missing, or the token does not match |
|
|
| The |
IG container stuck at | Meta cannot fetch the image, or it is being slow. Check the URL in a browser first. |
| Token expired. Mint a new long-lived Page token. |
| Scope missing — re-grant |
| Not a bug. The topic is already in |
First call of the day times out | Free-plan cold start. Hit |
Worker dies mid-render, no error | Out of memory. Chromium needs ~400 MB; the free instance is 512 MB. Move to |
| Working as intended. Shorten the named field and call |
|
|
|
|
Template file not found on Render |
|
Layout
src/
server.ts Express, bearer auth, /health, POST /mcp
config.ts Lazy env resolution, constants
log.ts Timed stdout logging
errors.ts AppError / MetaError, the no-retry rule for 190 & 200
supabase.ts posts CRUD + storage upload
meta.ts Graph client, IG container flow, FB photos, debug_token
image.ts Chromium lifecycle, template render, overflow guard, sharp
tools/
register.ts Timing, error envelope, content-block shaping
get_past_topics.ts reserve_topic.ts render_post.ts
publish_socials.ts mark_draft.ts check_token.ts
index.ts
templates/
tokens.css base.css design tokens + shared skeleton
news.html subject.html career.html
fonts/ barlow-400/600/700, cinzel-600 (woff2, self-hosted)
assets/ logo.png (supplied), logo-mark.png (derived)
migrations/001_init.sql
scripts/
smoke.ts render all three, upload, prove both guards fire
copy-templates.mjs build step: tsc emits only .ts, templates must reach dist/
prepare-logo.mjs derives logo-mark.png from logo.png
render.yamlThis server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables AI assistants to manage Instagram and Threads accounts — publish content, handle comments, view insights, search hashtags, and manage DMs through the Meta Graph API.594610MIT
- AlicenseAqualityDmaintenanceEnables Instagram and Facebook analytics via the Meta Graph API, offering account insights, media analytics, and post engagement data.196MIT
- AlicenseAqualityDmaintenanceMCP server to safely publish posts to multiple Facebook Pages via Meta Graph API, with built-in guardrails for brand voice, banned topics, image requirements, and anti-duplication.4MIT
- FlicenseNot gradedqualityCmaintenanceMCP server for automatic Instagram publishing — single image, carousel and Reels — via the official Instagram Graph API.40
Related MCP Connectors
Create, schedule and publish social posts to TikTok, Instagram, Facebook and YouTube.
Schedule and publish social posts to 11 platforms with media, campaigns, analytics and AI captions
Boost posts and launch community growth campaigns from your AI assistant. OAuth, credit-billed.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Weone404/mcp-content-cron'
If you have feedback or need assistance with the MCP directory API, please join our Discord server