Skip to main content
Glama
Weone404

weone-daily-post

by Weone404
README.md
# weone-daily-post — remote MCP server

Publishing backend for the We One Aviation daily Instagram + Facebook post.
It is a stateless [Streamable HTTP](https://modelcontextprotocol.io) 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}`. `category` ∈ `news`, `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

0. `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.)
1. **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 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.
2. **Facebook** — `POST {FB_PAGE_ID}/photos` with `url` and `message`. Attempted
   regardless of the Instagram outcome.
3. **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`.

## 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](#browser-lifecycle-and-memory). |

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 → **New** → **Blueprint** → 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. **New** → **Web 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:

```bash
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:

```bash
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):

```json
{
  "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

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

```bash
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 overwritten** — `upsert: 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.

### 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`:

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