Skip to main content
Glama
Weone404

weone-daily-post

by Weone404

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 photos

Tools

Tool

Purpose

get_past_topics()

Newest-first history, max 200 rows: {id, topic, category, status, created_at}. Read it before choosing a topic.

reserve_topic(topic, category)

Inserts status='reserved', returns {id}. categorynews, subject, career. A repeat topic fails with duplicate_topic.

render_post({template, headline, points, footer?, eyebrow?, slug?})

Renders a branded HTML template to an exact-size sRGB JPEG, uploads it, and returns an image block plus the public URL.

publish_socials(image_url, caption, hashtags, history_id)

Publishes to Instagram then Facebook, records the outcome.

mark_draft(history_id, image_url, caption)

Shadow mode: record the finished post as draft without publishing.

check_token()

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

headline

60 chars

Barlow 700, up to 3 lines. Sentence case, not title case.

points

3–4 items, 90 chars each

Barlow 400, one gold marker each

eyebrow

32 chars, optional

Gold, uppercased by CSS, e.g. NAVIGATION

footer

90 chars, optional

Left side of the footer bar, e.g. DGCA · 14 Aug 2026

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:

  1. Length limits, checked before Chromium is touched — the cheap rejection. points[2] is 97 characters, limit is 90. Shorten it and retry.

  2. 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

  1. HEAD the image_url and assert 200 + content-type: image/jpeg. Meta fetches this URL server-side, and a bad URL there fails opaquely hours later. (A store that rejects HEAD gets a one-byte ranged GET instead.)

  2. Instagramfull_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 with caption_too_long before anything is posted. POST {IG_USER_ID}/media → poll GET {container}?fields=status_code,status once a second for up to 60s → publish only on FINISHED. On ERROR the status string is returned verbatim, because it is the only place Meta explains what it disliked.

  3. FacebookPOST {FB_PAGE_ID}/photos with url and message. Attempted regardless of the Instagram outcome.

  4. Record — the posts row gets ig_post_id, fb_post_id, image_url, and status = published (both), partial (one) or failed (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

MCP_AUTH_TOKEN

yes

Shared secret for /mcp. The connector must send Authorization: Bearer <value>. If unset the server still boots and serves /health, but rejects every /mcp request with a 500 — it fails closed, never open. Generate one with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".

SUPABASE_URL

yes

https://<project-ref>.supabase.co.

SUPABASE_SERVICE_KEY

yes

Service-role key. Bypasses RLS — server-side only. Never put it in the connector config.

META_GRAPH_VERSION

no (default v23.0)

Graph API version used for every call.

IG_USER_ID

for publishing

Instagram Business account id (a number, not the @handle).

FB_PAGE_ID

for publishing

Facebook Page id linked to that Instagram account.

META_PAGE_ACCESS_TOKEN

for publishing

Long-lived Page access token with instagram_basic, instagram_content_publish, pages_show_list, pages_read_engagement, pages_manage_posts. Expires around 60 days — check_token() tells you how long is left.

PORT

no

Render sets this. Default 10000.

MAX_INLINE_IMAGE_BYTES

no (default 1400000)

Size above which the inline base64 preview is downscaled.

CHROMIUM_EXECUTABLE_PATH

no

Explicit path to a Chrome/Chromium binary. Overrides the per-platform default.

CHROMIUM_SINGLE_PROCESS

no

Set to 1 to force --single-process. Costs browser reuse — one render per launch. See Browser lifecycle.

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 posts table with the check constraints and the unique index on topic — that index is the repetition guard, so a duplicate reserve is supposed to fail,

  • created_at desc and status indexes,

  • RLS enabled on posts with no policies (only the service key gets in),

  • the public post-images storage 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):

  1. Push this repo to GitHub.

  2. Render dashboard → NewBlueprint → pick the repo. It reads render.yaml: Node 20, npm ci && npm run build, npm start, health check on /health.

  3. Render prompts for every sync: false variable. Paste them.

  4. Deploy, then check the logs for server.listening ... auth=configured. auth=MISSING means MCP_AUTH_TOKEN did not get set.

Manually:

  1. NewWeb Service → connect the repo.

  2. Runtime Node, build npm ci && npm run build, start npm start.

  3. Health check path /health.

  4. 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/mcp

with 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 start

npm run smoke

npm run smoke                 # render all three templates, upload, print 3 URLs
npm run smoke -- --no-upload  # render locally only, no credentials needed

Renders 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.css holds every colour; base.css holds 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 on document.fonts.ready and then asserts both faces actually loaded rather than screenshotting a fallback.

  • No user text is ever concatenated into markup. Values go in through textContent and createElement, 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 overwrittenupsert: false. Re-rendering a topic cannot change the picture under a post that already published the previous URL. cacheControl is 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 with overflow: hidden. Without it the absolutely-positioned graphic hangs past the bottom edge, counts toward body.scrollHeight, and the overflow guard rejects every render with a constant 160px page overflow.

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-logo

Browser 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

CHROMIUM_EXECUTABLE_PATH set

that path, always wins

Linux (Render)

@sparticuz/chromium, which ships its own binary so there is no browser download at build time

macOS / dev

whatever playwright-core already has cached (npx playwright-core install chromium)

--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

bad_input

Arguments failed validation.

duplicate_topic

The topic already exists. Working as designed — pick another.

not_found

No posts row for that history_id. Was reserve_topic called?

db_error / storage_error

Supabase said no. details carries the Postgres code.

image_generation_failed / image_too_large

Provider or sharp problem.

image_url_unreachable

The URL Meta would fetch is not a reachable JPEG.

caption_too_long

Caption body alone exceeds 2200 chars. Hashtags are trimmed automatically; the body never is.

meta_error

Graph API. details has code, error_subcode, type, fbtrace_id, unchanged.

timeout

Something exceeded its budget (image 60s, container poll 60s, Graph 30s).

config_error

A required environment variable is missing. retryable: false.

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

401 on every request

Header missing, or the token does not match MCP_AUTH_TOKEN.

500 config_error on /mcp, /health fine

MCP_AUTH_TOKEN is not set on the service.

image_url_unreachable

The post-images bucket is not public, or the upload silently failed. Run npm run smoke.

IG container stuck at IN_PROGRESS for 60s

Meta cannot fetch the image, or it is being slow. Check the URL in a browser first.

meta_error code 190

Token expired. Mint a new long-lived Page token. check_token() warns at ≤7 days.

meta_error code 200

Scope missing — re-grant instagram_content_publish / pages_manage_posts.

duplicate_topic

Not a bug. The topic is already in posts.

First call of the day times out

Free-plan cold start. Hit /health first, or move to starter.

Worker dies mid-render, no error

Out of memory. Chromium needs ~400 MB; the free instance is 512 MB. Move to starter.

bad_input naming a field

Working as intended. Shorten the named field and call render_post again — nothing was uploaded.

Self-hosted fonts failed to load

src/templates/fonts/ did not reach dist/. Re-run npm run build; the render is refused rather than shipped in a fallback serif.

Target page, context or browser has been closed

CHROMIUM_SINGLE_PROCESS=1 is set. That flag allows only one render per launch — unset it.

Template file not found on Render

npm run build was skipped, so dist/templates/ is missing.

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.yaml
F
license - not found
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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

  • A
    license
    A
    quality
    C
    maintenance
    Enables AI assistants to manage Instagram and Threads accounts — publish content, handle comments, view insights, search hashtags, and manage DMs through the Meta Graph API.
    59
    46
    10
    MIT
  • A
    license
    A
    quality
    D
    maintenance
    MCP 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.
    4
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    MCP server for automatic Instagram publishing — single image, carousel and Reels — via the official Instagram Graph API.
    40

View all related MCP servers

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.

View all MCP Connectors

Latest Blog Posts

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