About forkcaster
forkcaster resolves Farcaster cast references into deep links for supported client apps and serves shareable cast pages. Its public MCP endpoint also returns client URLs and the author’s signed-contract status without authentication.
Key features
- 01Resolve casts to client deep links
- 02Render server-side cast pages
- 03Return cast data as JSON
- 04Check author signed status
Use cases
- 01Share a cast without the Farcaster app
- 02Find a cast link for a chosen client
- 03Retrieve cast details for an agent
MCP support
A public, unauthenticated Streamable HTTP endpoint exposes the `get_cast_link` tool. It resolves a cast reference into supported client URLs and reports the author’s signed-contract status.
Configuration
Remote servers need no local install. Pick your client, then copy the config or install in one click.
https://forkcaster.xyz/mcpclaude mcp add --transport http xyz-forkcaster https://forkcaster.xyz/mcp{"mcpServers":{"xyz-forkcaster":{"type":"http","url":"https://forkcaster.xyz/mcp"}}}Open in Cursor{"servers":{"xyz-forkcaster":{"type":"http","url":"https://forkcaster.xyz/mcp"}}}Open in VS Code{"mcpServers":{"xyz-forkcaster":{"type":"http","url":"https://forkcaster.xyz/mcp"}}}{"mcpServers":{"xyz-forkcaster":{"serverUrl":"https://forkcaster.xyz/mcp"}}}Run commands in a terminal; paste JSON into the client’s MCP config and restart. Servers that need sign-in will prompt on first connect.
Tools
Details
From the project READMEforkcaster — agent brief
Audience: coding agents. Prefer facts over prose. Update this file when architecture or commands change.
Intent
Micro-site that mirrors Farcaster cast URLs for sharing without the Farcaster app.
- Pattern:
/{username}/{castHash}wherecastHashmatches0x[0-9a-fA-F]+— short or full form - Examples:
/zoo/0x895ecc74and/decent-artlu/0xbb54368f→ SSR cast card + links - Data: raced across providers (
worker/cast-source.ts, rpc-racer pattern) — see "Cast source race" below. shim (https://shim.artlu.xyz/wc/{username}/{hash}, owner-run Snapchain reader with a keep-warm cron) is the completeness anchor and the sole ban-signal dependency (/signed/{fid}); farcaster.tv is fast but quote-blind; raw nodes haatz/snap.farcaster.xyz:3381are complete but need shim/f/{username}for the fid and a full hash. All upstream calls are capped at 5s. - Missing cast: an authoritative miss from a complete provider (shim, haatz, snap) → 404 page; no provider resolved → link-only page (200). All upstream calls are capped at 5s (
AbortSignal.timeout) — shim's cold sharded lookups can otherwise hang 15s+; timeout ⇒ unreachable. Strict statuses are the JSON variant's job (see below) - Banned accounts: embeds render for everyone — but some clients hide unsigned authors. Since Oct 15, 2026 (Network School takeover), accounts must accept the "social smart contract" (Network Code); acceptance is an on-chain access NFT. Shim exposes it:
GET https://shim.artlu.xyz/signed/{fid}→{"signed":true|false}. Unsigned authors: registry clients withrequiresSigned(currently farcaster) are suppressed from the action row; the check fails open — unknown state suppresses nothing - farcaster.tv as a cast data source is quote-blind (it drops
{castId}embeds) and unofficial — it races as the partial tier only (700ms HTML box, client-side quote fill-in). react-farcaster-embed's own default endpoint is farcaster.tv — always passcastDataso it never fetches. The live stats row (replies/recasts/likes) is still fetched client-side from farcaster.tv — it sendsaccess-control-allow-origin: *and answers for unsigned authors too; any failure just leaves the row absent
Stack
| Layer | Choice | Notes |
|---|---|---|
| Runtime / package mgr | Bun | lockfile: bun.lock; scripts use bun run |
| UI (landing) | Preact 11 | SPA assets; entry src/main.tsx |
| Cast pages | Hono on Cloudflare Worker | SSR in worker/ |
| Cast data | Provider race (cast-source.ts) | shim ∥ farcaster.tv ∥ haatz ∥ snap — first validated win; completeness box for HTML, shim-only for JSON; see "Cast source race" below |
| URL embed cards | unfurl.artlu.workers.dev | called via the UNFURL service binding (workers.dev→workers.dev fetch is blocked, CF error 1042); GET /?u={url} → OG title/description/image (cached, fails open to hostname-only) |
| Cast embed | react-farcaster-embed | rendering only — castData always supplied, never fetches |
| Live stats | client-side script → farcaster.tv | LIVE_STATS_SCRIPT in cast-page.tsx; fills the SSR-stripped stats slot from the browser; fails silent (row absent); SSR data path stays Shim-only |
| SSR | preact-render-to-string | after await FarcasterEmbed({ castData }) |
| Bundler | Vite 8 | @preact/preset-vite + @cloudflare/vite-plugin |
| Lint | Biome | bun run lint |
| Typecheck | bun check | tsc-compatible, built into Bun; no typescript pkg |
| Tests | bun:test | bun test (cast route + client registry) |
Commands
bun install
bun run dev # wrangler dev (local worker + assets)
bun run build # vite build → dist/
bun run typecheck # bun check (tsc-compatible; reads tsconfig*.json)
bun run deploy # build + wrangler deploy
bun run lint # biome lint .
bun test # worker tests (cast route + client registry)
bun run cf-typegen # wrangler types → worker-configuration.d.ts
Layout
/
├── index.html # SPA shell → /src/main.tsx
├── vite.config.ts # preact + cloudflare + react→preact aliases
├── wrangler.jsonc # main=worker/index.ts; not_found_handling=none
├── public/farcaster-embed.css # linked from SSR HTML
├── public/logos/ # per-client marks for the action row
├── worker/
│ ├── index.ts # Hono app; export { app }; default { fetch }
│ ├── client-registry.ts # client registry (verified templates; zero-dep, vendored-able)
│ ├── clients.ts # fc_client cookie binding (last-used + auto-open prefs)
│ ├── shim.ts # shim identity client (routes, ShimCast, /wc /h /f /signed)
│ ├── cast-source.ts # provider race: shim ∥ haatz ∥ snap ∥ farcaster.tv
│ ├── cast-page.tsx # resolve (race) → CastData mapping → SSR / 404 / link fallback
│ ├── cast-ref.ts # MCP cast-ref: zod contract + shim-direct resolution
│ ├── mcp.ts # MCP SDK v2 server factory (get_cast_link) — only SDK import site
│ ├── cast-route.test.ts # bun:test; mock.module + exact-URL fetch mock
│ ├── clients.test.ts # registry unit tests: templates, gating, signed rule, deeplinks
│ ├── cast-source.test.ts # race unit tests: eligibility, box, miss/error, abort, adapter mapping
│ ├── race-stats-store.ts # lmdis snapshot read-through TTL cache (10min TTL / stale 24h / 2s cap)
│ ├── vendor/lmdis-sdk.ts # vendored lmdis SDK (Upstash-shaped seam; get/set only)
│ └── shims/server-only.ts # empty stub for Next.js marker package
├── scripts/
│ ├── race-stats.ts # offline stats job: AE SQL → snapshot → lmdis (dotenvx env; --dry)
│ ├── race-stats-lib.ts # pure query builders (fixture-tested)
│ └── probe/ # live-service probes (lmdis, AE SQL, shim timing)
└── src/ # landing only (App.tsx)
Routes
| Path | Handler | Response |
|---|---|---|
/ | static asset index.html | landing SPA |
/api/ | Hono | { "name": "Cloudflare" } |
/prefs | Hono | POST JSON { auto?: "on" | "off", delay?: 0–5 } (at least one field) → 204 + Set-Cookie (fc_client); delay omitted from the cookie at the default 5s. Auto-open preference write (server cookie — Safari ITP exemption) |
/transparency | Hono | HTML facts page (what/data/tracking/domains — Domains carries registration expiry + RDAP confirm links — plus race-stats table filled server-side from the lmdis snapshot, "stats as of" line) or JSON twin via ?format=json/Accept (racingStats.status: live | coming-soon); reserved exact path — cast URLs for a username named transparency still work |
/race-stats.json | Hono | raw stats snapshot (same cached read as /transparency); 404 when absent; cache-control: public, max-age=21600 |
/mcp | Hono → MCP SDK v2 | MCP Streamable HTTP endpoint (POST JSON-RPC; public, no auth). One tool, get_cast_link: cast reference (longhash ∥ fid+shorthash ∥ username+shorthash) + target client → client URLs + author signed status; farcaster.xyz is the canonical surface. |
/:username/:hash (0x…) | Hono SSR | HTML: 200 cast card + OG/Twitter meta (title = DisplayName (@handle), description = whitespace-collapsed cast text, 280 chars, image = first image embed else avatar; summary_large_image card only when the image is a cast embed image; og:site_name = forkcaster) + registry-driven client action row (see "Client registry" below); unsigned author (/signed/{fid} false): requiresSigned clients (farcaster) suppressed; 404 when Shim reports no such cast; 200 link-only when Shim is unreachable |
/:username/:hash?format=json (or Accept: application/json) | Hono JSON | Strict-status agent variant — see "Agent JSON variant" below |
| other missing paths | worker 404 / no SPA fallback | not_found_handling: none |
Multi-domain (dual-serve): the same worker serves forkcaster.xyz and warpcats.com (custom-domain routes in wrangler.jsonc). All code is host-agnostic (per-request origin, same-origin fill script, host-only cookie), and every page canonicalizes to the primary: cast pages emit <link rel="canonical"> + og:url from the PRIMARY_ORIGIN var (absent when unset — bare workers.dev keeps the pre-dual-serve output), the landing's canonical is baked into index.html. Race telemetry carries the request host as a blob for per-domain breakdown. Cookies/badges are per-domain by design.
Agent JSON variant (?format=json)
Shipped. Resolve once (Shim fetch + unfurl + quote hydration + signed check → resolveCast()), render twice — renderCastHtml() / renderCastJson() in worker/cast-page.tsx. The return type morphs on the asker.
Negotiation — explicit first, no UA sniffing:
| Signal | Response |
|---|---|
?format=json query param | JSON (documented contract; wins over Accept) |
Accept: application/json | JSON |
anything else (browsers, */*, chat link-preview bots) | HTML |
Decisions:
- JSON only — no kv/plain-text form (kv is lossy; JSON's token overhead is negligible).
- Strict statuses on JSON, lenient on HTML (deliberate divergence): JSON → 404 when Shim reports no such cast, 503 +
Retry-After: 60when Shim is unreachable. HTML keeps its 404 page / 200 link-only page. Agents handle status codes better than rendered fallbacks. - Availability-gated payload — a field appears only when its data exists: author, ISO 8601 timestamp, text, image embed URLs, URL embeds (unfurl title appended when unfurl succeeded, bare URL otherwise), quote embeds, canonical farcaster.xyz + Firefly links.
- Quotes are pointers, never dropped: hydrated quote → forkcaster URL (requires the username from the hydrated cast); hydration failure → Firefly full-hash URL (quote embeds carry
{fid, hash}, no username). HTML's rendering constraints (unguardedauthor.pfp.url) don't apply to JSON — failed hydration degrades to a URL the agent can fetch. - Ban signal:
signed: falseemits a short note field; unknown/failed check fails open silently (same fail-open semantics as the HTML action row). - Discoverability: HTML pages emit
<link rel="alternate" type="application/json" href="?format=json">. - Deferred: UA-allowlist morphing (GPTBot, ClaudeBot, …) as a later progressive enhancement. Must stay an allowlist — Telegram/Discord/iMessage preview bots must keep receiving HTML + OG meta.
- No page-level caching today (Shim is the cache); if edge caching is ever added, the variant must join the cache key.
MCP
https://forkcaster.xyz/mcp (also warpcats.com/mcp) — MCP Streamable HTTP endpoint, public, no auth, stateless. One tool: get_cast_link — resolve a Farcaster cast (full 40-hex hash, or fid/username + short hash) into deep-link URLs for any supported client, with the author's signed-contract status. Wiring: worker/mcp.ts (SDK) + worker/cast-ref.ts (zod contract, shim-direct resolution).
Registry listing: server.json (repo root, official registry schema 2025-12-11, name xyz.forkcaster/mcp). To publish, install the official publisher CLI — a GitHub-release binary. ⚠️ do NOT bunx/npx the npm package mcp-publisher — it's an unrelated browser-automation project that collides with the name:
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher && sudo mv mcp-publisher /usr/local/bin/
One-time domain verification: generate an ed25519 keypair (openssl genpkey -algorithm Ed25519 -out key.pem), add the apex TXT record it prints (v=MCPv1; k=ed25519; p=<public key>), then:
mcp-publisher login dns forkcaster.xyz --private-key <hex> # signs the challenge → xyz.forkcaster namespace
mcp-publisher publish # submits server.json to registry.modelcontextprotocol.io
key.pem is the publishing credential — keep it safe, never commit it.
Cast source race
Cast lookups race four providers (worker/cast-source.ts, rpc-racer pattern: fan out with per-provider abort + 5s cap, first validated win, losers aborted; a win must resolve the requested hash, else it's downgraded to an error).
Sufficiency matrix (measured Oct 2026) against the internal ShimCast contract (text, hash, unix-seconds ts, raw {url}/{castId} embeds, inline user data):
| Provider | Resolves (username, hash)? | Quotes ({castId}) | Verdict |
|---|---|---|---|
| shim | ✅ short + full | ✅ | complete — canonical, wins anytime |
haatz (haatz.quilibrium.com/v1) | ⚠️ full hash only + fid via shim /f/{username} | ✅ | complete — full-hash requests |
snap (snap.farcaster.xyz:3381, official gateway; 443 hangs) | ⚠️ same as haatz | ✅ | complete — full-hash requests |
| farcaster.tv | ✅ short (prefix-match) | ❌ silently dropped | partial — quote-blind |
HTML path (700ms completeness box): complete providers win anytime; a quote-blind farcaster.tv win is held until the box expires, then served as a partial page — the SWR tradeoff the OG meta is immune to: title/description/image never depend on quotes, so chat-bots' cached embed cards are always complete. The partiality only reaches live browsers, which self-heal: partial pages ship QUOTE_FILL_SCRIPT, which fetches ?embeds=quotes (shim-resolved, quote-hydrated SSR fragment, no-store) and injects it — same silent-degradation contract as the live-stats script. No server-side cache is introduced: the last-used badge and signed suppression stay cache-key-free.
JSON path: shim-only (boxMs: null) — always complete, strict statuses (404 / 503 + Retry-After), the never-drop-quotes invariant holds unconditionally.
Miss semantics: shim's miss resolves not-found immediately (fast 404s); a miss from any complete provider (haatz/snap raw nodes' 400) is authoritative ⇒ 404 even if a partial landed; farcaster.tv 404 alone never produces a 404 (cast might exist). Everything-else-failed ⇒ link-only page (HTML) / 503 (JSON), fail-open as usual.
Ban signal: NOT raced — /signed/{fid} stays a shim dependency, orthogonal to cast content, time-boxed to the HTML box with the usual fail-open.
Telemetry: every resolve writes one Analytics Engine datapoint (RACE_STATS binding, non-blocking via waitUntil): index = winning provider (or outcome kind), doubles = total ms / box / partial flag, blob = per-provider id:kind:ms rows (race-abort losers record abort, not error). Query via the AE SQL API when tuning the box (scripts/probe/, scripts/race-stats.ts); tests and callers without an executionCtx skip it.
Follow-ups: quote hydration could race haatz/snap (/h/{fullHash} vs castById — the castId embed carries the fid); a ?format=json consumer willing to trade completeness for latency could opt into the race.
Race stats (/transparency)
The racing-stats table is filled offline — the render path stays cache-free and stats-free. bun run stats (env per .env.example) queries Analytics Engine through the SQL API — sample-weighted (sum(_sample_interval), quantileExactWeighted; never raw count()) — assembles a versioned snapshot (v: 1; wins/requests/p50/p95/errorRate per provider + outcome counts) and publishes it as one lmdis key (race-stats on lmdis.artlu.xyz, the owner-run Redis-shaped KV) through the vendored Upstash-shaped SDK. Overwrite-only-on-success; --dry previews.
The transparency route reads the snapshot through a per-isolate TTL cache (worker/race-stats-store.ts: 10 min TTL, 60s failure backoff, last-good served ≤24h, 2s read cap) and fills the table, the "stats as of" line, and the JSON twin's racingStats — absent/unreachable snapshot ⇒ "coming soon" dashes, silently. /race-stats.json serves the raw snapshot (404 when absent). SQL dialect + store behavior are live-verified by scripts/probe/ (the AE dialect has no CONCAT and only toUInt32/toUInt8 conversions; SQL errors come back as plain-text 422s; the SQL API rejects Account Owned Tokens — use a User token with Account Analytics:Read).
Abort telemetry: race-cut losers are recorded as provider:abort:<ms> (not error) so public errorRate stays honest — the 700ms box cuts slow completes by design; the 5s hard-timeout still counts as an error. raceCast drains pending settles after abort (bounded 25ms) so timing rows never escape incomplete.
Client registry (action row, last-used badge)
The cast page's "Open in …" pills are rendered from a data registry, worker/clients.ts — adding a client is a data change only.
- Schema (
ClientDefinition):id(stable slug, cookie value),title,logoUrl(served frompublic/logos/),web(per-cast https template),deeplink(optional native mobile target — activates the click-time scheme attempt described below; absent ⇒ web-only),requiresSigned(suppressed only on a knownsigned:false; unknown fails open). - Templates interpolate
{username},{hash}(route-form, short or full) and{fullHash}(Shim-resolved). The engine also supports an optional{fid}placeholder with amissingVarsgate (a client needing it is skipped when the value is absent) — reserved for future entries. Values are percent-encoded at resolution and HTML-escaped at render. - Mobile deeplinks vs web targets: one pill per client; the
hrefis always thewebURL. Universal links already open installed apps on mobile — verified per client: Farcaster's AASA claims all farcaster.xyz paths, Firefly's AASA explicitly claims/post/*(Android:com.farcaster.mobileandio.dimension.fireflyconfirmed via assetlinks.json). A structureddeeplink: { scheme, androidPackage? }adds a native attempt for mobile only: the pill gainsdata-scheme+data-intent(the intent:// form carriesS.browser_fallback_url= the web URL), and a conditional script intercepts clicks on coarse-pointer devices — Android uses the intent (Chrome-native fallback), everything else attempts the scheme and falls back to the web href if the app didn't open (~1.8s, visibility-aware). The script ships only when a rendered client actually has adeeplink. Farcaster currently shipsfarcaster://{username}/{fullHash}— verified against the open-source app (farcasterxyz/client): the app rewritesfarcaster://tohttps://farcaster.xyz/and routes the rest as a web path, where cast URLs are/{username}/{hash}accepting full or short (≥8 hex) hashes; the legacy/casts/:fname/:hashroute is gone. Firefly ships no scheme — none is documented anywhere; its web URL is already app-linked on both platforms. - Roster — all five clients have verified per-cast URLs: farcaster (
/{username}/{hash}), firefly, neynar, quilcast and vibeshift (full-hash based; neynar verified againstfc-clients-cast-actionsrc/lib/deeplink.ts; quilcast against backmeupplz/quilcastserver/index.mjs—/c/{fullHash}, 40 hex with optional0x, live-checked; browser-only client, no native app; vibeshift?view=cast&castHash={fullHash}&castFid={fid}— needs the cast originator's fid,{fid}-gated so it's skipped on fid-less surfaces, owner-provided + user-verified). - Last-used cookie: any click on a
data-clientpill writesfc_client=<id>(1 year,SameSite=Lax) viaCLIENT_SCRIPT; on the next request the worker reads it and SSRs a small "last used" badge onto that client's pill. Unknown cookie values render nothing (registry whitelist). No cookie ⇒ no badge; no JS ⇒ links still work, the badge just doesn't move. Cookie-conditional HTML: if an edge cache is ever added, the badge must join the cache key. - Logos: self-hosted in
public/logos/, one static file per client — farcaster and quilcast as SVG, firefly, neynar and vibeshift as PNGs (vibeshift: its android-chrome icon downscaled to 64px; the site's favicon.svg is an unrelated template default, not the brand mark). The pill's<img>renderslogoUrluniformly; no per-client code paths.
Shim → CastData mapping
Providers return different shapes; cast-source.ts normalizes everything onto ShimCast (shim's raw Snapchain shape). toCastData() in worker/cast-page.tsx then adapts it for react-farcaster-embed:
timestampis unix seconds → multiplied by 1000 (the embed doesnew Date(ts))embedsare untyped{url}/{castId}items → bucketed intoimages/urls/casts; image heuristic =imagedelivery.nethost or image extension. Quotes are hydrated through ShimGET /h/{fullHash}(depth 1) and mapped like any cast; hydration failure (e.g. pruned casts) drops the quote. URL embeds render as link cards viaopenGraph(see unfurl above)- message-level data has no engagement counts →
replies/reactions/recastsstubbed to{ count: 0 }andtags: []— the embed bundle readse.recasts.countande.tags.lengthunguarded and crashes without them (it also readsquote.author.pfp.urlunguarded — never emit quotes without hydrated authors). The stubs would render as hardcoded zero counters, so the stats row is stripped from the SSR output (STATS_ROW); the Farcaster logo in the same container stays authormapped fromuser(pfpUrl→pfp.url,bio→profile.bio.text)
Conventions for agents
- Preact, not React for first-party code. Vite aliases map
react*→ preact for the embed lib. - Cast routing is server-side (Hono), not client-side routing.
- Tests:
mock.module("server-only"|react*)before importing./index.ts; mockglobalThis.fetchby exact Shim URL — unexpected URLs throw, which is how fallback paths get tested. - TS worker:
jsxImportSource: preact; relative imports need.tsxextensions (nodenext); tests excluded fromtsconfig.worker.json. - Secrets:
.env*/.dev.vars*gitignored.
Key files
| Task | Touch |
|---|---|
| Shim fetch / mapping / SSR fallbacks | worker/cast-page.tsx |
| Provider race / cast data sources | worker/cast-source.ts |
| Client roster / action row data | worker/clients.ts |
| Routes | worker/index.ts |
| Aliases | vite.config.ts, tsconfig.worker.json paths |
| Landing | src/App.tsx |
| Deploy / CF | wrangler.jsonc |
Status
Shim-first SSR data path raced across shim / farcaster.tv / haatz / snap (cast-source.ts); HTML serves quote-blind wins after a 700ms box and self-heals quotes client-side; JSON stays shim-complete and strict. Live stats still fetched client-side from farcaster.tv. Agent JSON variant (?format=json) shipped. Registry-driven action row shipped: last-used badge, Farcaster native deeplink (farcaster://, verified), four verified per-cast client URLs.
Comments
New to MCP?
From what the protocol is to installing your first server, these answers cover most first questions.
An open protocol that defines how AI applications connect to external tools and data in a uniform way. Clients talk to the model; MCP servers provide tools, resources and prompt templates. They exchange standard messages, so one server can be reused by many clients.