Cross-post to dev.to, Hashnode, Medium. Recommended canonical URL: your personal blog if you have one, otherwise dev.to.
Cover image suggestion: a screenshot of the hub homepage with the 17 product cards visible.
TL;DR
Over a few weeks I built 17 customer-facing Model Context Protocol (MCP) servers plus an analytics service and a master hub. Each one is a thin Cloudflare Worker that wraps a free public data source into a tool surface AI agents can call. Source is MIT, hosted on Cloudflare's free tier, with an anonymous free tier on every product. Hub: https://mcp-hub.atlasword.workers.dev/
This post is about the architecture, the economics, and the parts that are harder than they look.
Why a portfolio of 17 instead of one polished product
The conventional indie-hacker advice is: pick one wedge, go deep, become the best-in-class for that one thing. I deliberately did the opposite. Three reasons.
1. The marginal cost of an additional MCP is small. Once you have the template — Worker entrypoint, KV cache, quota counter, Stripe webhook, OpenAPI generator, smithery.yaml, server.json, npm wrapper — the only product-specific code is the upstream API client and the tool definitions. For most public APIs that's 200-500 lines of TypeScript.
2. The marginal cost of an additional listing is also small. Submitting to Smithery, Glama, mcp.so, the official registry, awesome-mcp lists, etc., is per-product effort. But you can template the listing content too. So 17 listings on 8 directories is 136 submissions — but the marginal one is two minutes once the script is written.
3. I genuinely don't know which buyer will show up first. A portfolio is a hedge against being wrong about the buyer. If SEC EDGAR doesn't take off but GST validator does, fine — I follow the signal. Building one wedge would have locked me into a guess about the buyer.
That's the thesis. Whether it's right is testable, not arguable.
The architecture
Each product is a single Cloudflare Worker. The diagram is:
Claude / Cursor / Cline / any MCP-compatible agent
|
| MCP-over-HTTP (JSON-RPC 2.0, POST /mcp)
v
Cloudflare Worker
/ \
/ \
KV: usage KV: cache
(quota counter) (upstream API responses)
\ /
\ /
v v
Upstream public API
(SEC EDGAR, openFDA, GDELT, ...)
Per worker:
-
src/index.ts— MCP server, routes/mcp,/openapi.json,/llms.txt,/upgrade -
src/client.ts— upstream API client with retries -
src/tools.ts— MCP tool definitions (the part that takes the longest) -
src/usage.ts— KV-backed quota counter (shared across all 17, copy-pasted) -
src/stripe.ts— Stripe webhook handler (shared across all 17, copy-pasted)
Total ~500-1500 LOC TypeScript per product. The shared code is roughly 300 LOC across all products.
The MCP-over-HTTP transport
A lot of MCP servers in the wild are stdio-only npm packages. That works but it's clunky — every server you add to Claude Desktop spawns a child process at startup, every request requires JSON serialization over a pipe, and it's hard for non-desktop clients to use them.
MCP-over-HTTP is just JSON-RPC 2.0 over POST. The endpoint accepts:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "edgar_search_filings",
"arguments": { "cik": "0000320193", "form": "10-K", "limit": 3 }
}
}
And returns:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "Found 3 10-K filings for AAPL: ..." }
]
}
}
You can curl this. You can call it from any HTTP client. Claude Desktop's HTTP MCP support is recent; Cursor, Cline, and most newer agents speak it natively. For desktop fallback, I ship an npm wrapper (@insnapsprakhar/<slug>-mcp) that bridges stdio → HTTP.
Quota metering on KV
Workers KV is eventually consistent, which sounds scary for a counter but is fine in practice. Burst tolerance > strict correctness.
async function incrementUsage(env: Env, key: string): Promise<number> {
const current = await env.USAGE.get(key)
const next = (current ? parseInt(current) : 0) + 1
await env.USAGE.put(key, next.toString(), { expirationTtl: 60 * 60 * 24 * 31 })
return next
}
async function checkQuota(env: Env, apiKey: string | null, ip: string): Promise<QuotaStatus> {
const key = apiKey ?? `anon:${ip}`
const month = new Date().toISOString().slice(0, 7)
const usage = await incrementUsage(env, `usage:${key}:${month}`)
const limit = apiKey ? lookupTierLimit(apiKey) : 100 // anon free tier
return { used: usage, limit, blocked: usage > limit }
}
That's the whole metering surface. ~30 LOC. No D1, no Postgres, no Redis. The race condition during a burst is a few extra calls beyond the limit, which is a fine user experience for a free tier.
The hardest part: tool surface design
The architecture took maybe 3 weeks of figuring out. The shipping took weeks. The hard part was — and still is — deciding what the right 5-10 MCP tools per data source are.
A tool that's too generic (edgar_query, takes any URL) gives the LLM no leverage; it has to construct the URL itself. A tool that's too narrow (edgar_get_apple_10k, one company) doesn't compose. The right surface is somewhere in between, and depends on what kinds of questions the agent is being asked.
For SEC EDGAR I settled on:
edgar_search_filings(cik?, form_type?, date_from?, date_to?, limit?)edgar_read_filing(accession_number, section?)edgar_get_facts(cik, concept?)edgar_get_8k(cik, item_number?, since?)-
edgar_get_company(query)— name/ticker → CIK edgar_get_insider_trades(cik|name, since?)
Six tools. Each one composes with the others — you call edgar_get_company to get a CIK, then edgar_search_filings, then edgar_read_filing. The tools intentionally don't pre-summarize because LLMs are better at that than I am.
I am genuinely uncertain whether 6 is the right number for every product. Two of the simpler products (gst-validator, hsn-classifier) have 3 tools each and feel right. The more complex ones (indian-regulatory, indic-normalize) have 9-12 and feel like they're at the upper limit before the LLM starts misrouting.
Distribution: harder than the code
There are at least 8 directories where you can list an MCP:
| Directory | Mechanism | Time per submission |
|---|---|---|
Official modelcontextprotocol/registry
|
mcp-publisher CLI + GitHub OIDC |
~2 min (after CI setup) |
| Smithery | smithery.yaml in repo, auto-crawled | ~0 min (passive) |
| Glama | Auto-crawls awesome-mcp-servers | ~0 min (passive after PR merges) |
| mcp.so | GitHub issue or web form | ~5 min per product |
| PulseMCP | Web form | ~5 min per product |
| MCPMarket | Web form / OAuth | ~5 min per product |
| Cursor MCP gallery | PR to Cursor's repo | varies |
| punkpeye/awesome-mcp-servers | PR | once for all 17 |
Even with templating, 17 products × 8 directories = 136 submissions. Not all are programmatic. The directories that auto-crawl (Smithery, Glama) you get for free once your repo metadata is right. The ones that require manual submission are the bottleneck, and the way I've handled it is to script-generate the issue/PR bodies and submit in batches.
Economics
Cloudflare Workers free tier: 100k requests/day, KV reads 100k/day free, writes 1k/day. The paid plan is $5/mo and would cover the entire 17-product directory at meaningful scale.
Per product:
- Free tier: ~100 calls/month/IP, anonymous
- $5-9/mo paid tier: 5k-10k calls/month
- $19-29/mo: premium tools + higher quota
- $79+ tiers: bulk endpoints, team accounts
Break-even per product is roughly one paying customer at $9/mo (covers shared Stripe + Worker bundle costs + my time at $50/hr against the ~6h spent). Across the portfolio, break-even is ~10 paying customers total. Today: zero. This is launch day, not a postmortem.
What I'd do differently
Build the first 3 hard, validate distribution, then template. I built the first 8 in parallel before realizing the distribution flow needed work. Should have shipped 1-2-3 sequentially with full distribution per product, then parallelized.
Stripe on Day 0, not Day 5. I delayed Stripe until product 5 thinking free tier would generate signups first. Free tier generates calls, not signups. Conversion happens via in-app upgrade prompts that need a payment surface from the start.
Pick a sharper buyer for at least one product. "Anyone who wants SEC data" is not a buyer. "Equity research analysts at funds under $500M AUM who pay $50+/mo for Bloomberg-alternative tools" is a buyer. I deliberately stayed generic on most products as a hedge; for at least one, I should have been narrow.
Code
All 17 products are MIT, public on GitHub: https://github.com/guptaprakhariitr
Hub: https://mcp-hub.atlasword.workers.dev/
If you want to fork the pattern — it's the same Worker template across all of them, with the upstream client swapped — you can. The _template directory under no-grav/products/ has the bones.
Happy to answer questions in the comments — especially about Workers KV economics, the metering flow, or the tool-surface design choices.
If you found this useful, the easiest way to support is to try one of the 17 servers with your AI agent and tell me where the tool definitions are wrong. That's the part I'm least confident on.
Top comments (0)