mcpField
F
forkcaster

forkcaster

Remote
forkcaster.xyzSocial Media3Updated 1d ago

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

  1. 01Resolve casts to client deep links
  2. 02Render server-side cast pages
  3. 03Return cast data as JSON
  4. 04Check author signed status

Use cases

  1. 01Share a cast without the Farcaster app
  2. 02Find a cast link for a chosen client
  3. 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.

Endpointhttps://forkcaster.xyz/mcp
Copy install command
Claude CodeTerminal
Cursor.cursor/mcp.json
VS Code.vscode/mcp.json
Claude Desktopclaude_desktop_config.json
Windsurf~/.codeium/windsurf/mcp_config.json

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 README

forkcaster — 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} where castHash matches 0x[0-9a-fA-F]+ — short or full form
  • Examples: /zoo/0x895ecc74 and /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:3381 are 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 with requiresSigned (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 pass castData so it never fetches. The live stats row (replies/recasts/likes) is still fetched client-side from farcaster.tv — it sends access-control-allow-origin: * and answers for unsigned authors too; any failure just leaves the row absent

Stack

LayerChoiceNotes
Runtime / package mgrBunlockfile: bun.lock; scripts use bun run
UI (landing)Preact 11SPA assets; entry src/main.tsx
Cast pagesHono on Cloudflare WorkerSSR in worker/
Cast dataProvider 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 cardsunfurl.artlu.workers.devcalled 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 embedreact-farcaster-embedrendering only — castData always supplied, never fetches
Live statsclient-side script → farcaster.tvLIVE_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
SSRpreact-render-to-stringafter await FarcasterEmbed({ castData })
BundlerVite 8@preact/preset-vite + @cloudflare/vite-plugin
LintBiomebun run lint
Typecheckbun checktsc-compatible, built into Bun; no typescript pkg
Testsbun:testbun 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

PathHandlerResponse
/static asset index.htmllanding SPA
/api/Hono{ "name": "Cloudflare" }
/prefsHonoPOST 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)
/transparencyHonoHTML 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.jsonHonoraw stats snapshot (same cached read as /transparency); 404 when absent; cache-control: public, max-age=21600
/mcpHono → MCP SDK v2MCP 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 SSRHTML: 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 JSONStrict-status agent variant — see "Agent JSON variant" below
other missing pathsworker 404 / no SPA fallbacknot_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:

SignalResponse
?format=json query paramJSON (documented contract; wins over Accept)
Accept: application/jsonJSON
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: 60 when 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 (unguarded author.pfp.url) don't apply to JSON — failed hydration degrades to a URL the agent can fetch.
  • Ban signal: signed: false emits 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):

ProviderResolves (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 droppedpartial — 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 from public/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 known signed: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 a missingVars gate (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 href is always the web URL. 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.mobile and io.dimension.firefly confirmed via assetlinks.json). A structured deeplink: { scheme, androidPackage? } adds a native attempt for mobile only: the pill gains data-scheme + data-intent (the intent:// form carries S.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 a deeplink. Farcaster currently ships farcaster://{username}/{fullHash} — verified against the open-source app (farcasterxyz/client): the app rewrites farcaster:// to https://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/:hash route 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 against fc-clients-cast-action src/lib/deeplink.ts; quilcast against backmeupplz/quilcast server/index.mjs — /c/{fullHash}, 40 hex with optional 0x, 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-client pill writes fc_client=<id> (1 year, SameSite=Lax) via CLIENT_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> renders logoUrl uniformly; 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:

  • timestamp is unix seconds → multiplied by 1000 (the embed does new Date(ts))
  • embeds are untyped {url} / {castId} items → bucketed into images / urls / casts; image heuristic = imagedelivery.net host or image extension. Quotes are hydrated through Shim GET /h/{fullHash} (depth 1) and mapped like any cast; hydration failure (e.g. pruned casts) drops the quote. URL embeds render as link cards via openGraph (see unfurl above)
  • message-level data has no engagement counts → replies/reactions/recasts stubbed to { count: 0 } and tags: [] — the embed bundle reads e.recasts.count and e.tags.length unguarded and crashes without them (it also reads quote.author.pfp.url unguarded — 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
  • author mapped from user (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; mock globalThis.fetch by exact Shim URL — unexpected URLs throw, which is how fallback paths get tested.
  • TS worker: jsxImportSource: preact; relative imports need .tsx extensions (nodenext); tests excluded from tsconfig.worker.json.
  • Secrets: .env* / .dev.vars* gitignored.

Key files

TaskTouch
Shim fetch / mapping / SSR fallbacksworker/cast-page.tsx
Provider race / cast data sourcesworker/cast-source.ts
Client roster / action row dataworker/clients.ts
Routesworker/index.ts
Aliasesvite.config.ts, tsconfig.worker.json paths
Landingsrc/App.tsx
Deploy / CFwrangler.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

Markdown supported

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.