case study · 01
mdingest
liveArticles in, clean Markdown out. Built for the readers that aren't human.
An ingestion service that turns blog posts and newsletters into LLM-ready Markdown — exposed as a versioned HTTP API, a CLI, and an MCP server, all backed by one core.
the problem
Feeds aren't machine-legible
My own workflow needed it first: I keep a learning vault and feed articles to LLMs constantly. Medium sits behind a paywall wall, Dev.to and Substack wrap content in markup soup, and copy-paste loses title, author, and date.
Existing scrapers try to be universal — one readability pass for the whole web. The output shows it. The insight: three platforms cover most of what engineers actually read, and each has a structured path to the content. Build for those properly instead of everything badly.
the shape
One core, four doors
Everything resolves to a single ingest(url) that detects the provider and delegates. The surfaces are thin.
GET /v1/{provider}?url=
versioned HTTP API — the public surface
mdingest <url>
CLI for humans and scripts
MCP over stdio
local agents: Claude, Cursor, Devin
MCP over HTTP
remote agents hitting the deployed service
the calls
Decisions, and what they beat
One ingestion core, four entry points
rejected · Separate implementations per surface
HTTP API, CLI, and MCP (stdio + HTTP) all call the same `ingest()` router. A fix in the core fixes every surface; a new provider ships everywhere at once.
A provider module per source
rejected · Generic HTML scraping for everything
Dev.to has a real JSON API, Substack exposes /api/v1/posts, Medium needs a mirror. Structured sources beat readability heuristics — each provider owns its quirks behind one interface.
NestJS inside a Cloudflare Container
rejected · A plain Worker with hand-rolled routing
The provider matrix grows. DI, modules, and validation pipes are worth the container — the Worker is just a Durable Object proxy to it.
Markdown + YAML frontmatter as the output
rejected · JSON with embedded html
The consumers are LLMs and agents. Frontmatter carries title, author, date, reading time, and source_url — metadata they can use without parsing the body.
Validation errors with trace IDs
rejected · Generic 400s
Every failure returns a code (VALIDATION.FAILED, DEVTO.UNAVAILABLE) plus a traceId. When a user reports a bug, the ID is the whole conversation.
118
tests, including per-provider fixtures
4
entry points on one core: API · CLI · MCP stdio · MCP http
3
providers: medium · devto · substack
~0.4s
observed response on a warm container
proof
A real response
Captured from the live API while building this page: one Dev.to article in, 380ms later, publication-ready Markdown out. Frontmatter carries the metadata an LLM actually needs.
try it live--- title: "We All Have a \"Serious Work\" AI and a \"Just Vibing\" AI. When Did That Happen?" subtitle: "Hi Guys!!! As you know I wasn't good for well, a week and Now..... Let's Dive In!!! I never..." author: "Dhruv Jani" date: "2026-09-22T15:27:32Z" published: "2026-09-22T15:27:32Z" reading_time: "4 min read" free: true source_url: "https://dev.to/dj29/we-all-have-a-serious-work-ai-and-a-just-vibing-ai-when-did-that-happen-5fl2" provider: "devto" tags: - "discuss" - "ai" - "gemini" - "antigravity" --- Hi Guys!!! As you know I wasn't good for well, a week and Now.....  Let's Dive In!!! I never consciously decided which AI gets which job. Somehow, I just ended up with a ranking in my head. It just happened — one small decision at a time, until one day you noticed you have an entire unconscious hierarchy, and you can't fully explain how it got built. Here's mine, laid bare. ## The IDE has its own pecking order I use Antigravity as my AI IDE now — GitHub Copilot went pay-to-use, so that door closed. Inside Antigravity, I don't use one model. I use two, and which one shows up depends on what's actually at stake.
honestly
What I'd do differently
- Caching should have been day one, not later. Same article requested twice pays the upstream cost twice — a KV layer with content-hash keys is the obvious next layer.
- CORS stayed closed longer than it should have. The API is server-callable only today; opening it with rate limits would let people build on it directly.
- The provider interface earned its keep — Medium's quirks would have leaked everywhere without it. The pattern I underused: shared retry/backoff middleware instead of per-provider handling.
More case studies as the lab ships.
← back to selected work