-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBuild-Your-Own-MCP-Server.html
More file actions
1052 lines (1020 loc) · 67.5 KB
/
Copy pathBuild-Your-Own-MCP-Server.html
File metadata and controls
1052 lines (1020 loc) · 67.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Build Your Own MCP Server With Auth and Billing — In 30 Minutes</title>
<style>
:root {
--bg: #fcfcf8;
--fg: #1a1a1a;
--muted: #555;
--accent: #8b5cf6;
--accent-2: #38bdf8;
--rule: #e5e5e5;
--code-bg: #2a2438;
--code-fg: #e7d9ff;
--inline-bg: #efeaf6;
--inline-fg: #5b3a91;
}
* { box-sizing: border-box; }
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: var(--bg); color: var(--fg);
max-width: 760px; margin: 0 auto; padding: 48px 32px 96px;
line-height: 1.65;
}
h1 { font-size: 2.25em; line-height: 1.15; margin: 1.4em 0 0.5em; letter-spacing: -0.025em; color: #111; }
h2 { font-size: 1.55em; line-height: 1.2; margin: 1.6em 0 0.4em; letter-spacing: -0.02em; color: #1a1a1a; padding-top: 0.6em; border-top: 1px solid var(--rule); }
h3 { font-size: 1.2em; line-height: 1.25; margin: 1.4em 0 0.4em; color: #222; }
h4 { font-size: 1.05em; margin: 1.3em 0 0.3em; color: #333; }
p { margin: 0.65em 0; }
ul, ol { margin: 0.6em 0; padding-left: 1.3em; }
li { margin: 0.2em 0; }
strong { color: #000; font-weight: 700; }
em { color: #444; }
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); }
a:hover { border-bottom-color: var(--accent); }
hr { border: 0; border-top: 1px solid var(--rule); margin: 2em 0; }
blockquote {
margin: 1em 0; padding: 0.6em 1em;
background: #f3eefb; border-left: 4px solid var(--accent);
color: #2a1f48; border-radius: 0 6px 6px 0;
}
blockquote p { margin: 0.3em 0; }
code {
background: var(--inline-bg); color: var(--inline-fg);
padding: 1px 6px; border-radius: 4px;
font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace;
font-size: 0.92em;
}
pre {
background: var(--code-bg); color: var(--code-fg);
padding: 18px 20px; border-radius: 10px;
overflow-x: auto;
font-size: 0.86em; line-height: 1.6;
margin: 1em 0;
border: 1px solid #1a1525;
}
pre code { background: none; color: inherit; padding: 0; font-size: inherit; }
table { border-collapse: collapse; margin: 1em 0; font-size: 0.95em; }
th, td { border: 1px solid var(--rule); padding: 6px 12px; text-align: left; }
th { background: #f3f1ec; }
.toc {
background: #f6f3ed;
border: 1px solid var(--rule);
border-radius: 8px;
padding: 18px 22px;
margin: 2em 0;
}
.toc h2 { font-size: 0.9em; margin: 0 0 0.5em; padding: 0; border: none; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); }
.toc ol { margin: 0; padding-left: 1.4em; }
.toc a { color: #333; border: none; }
.toc a:hover { color: var(--accent); }
.footer {
margin-top: 4em; padding-top: 1.2em;
border-top: 1px solid var(--rule);
font-size: 0.9em; color: var(--muted); text-align: center;
}
@media print {
body { max-width: none; padding: 0; font-size: 11pt; }
a { color: var(--fg); border: none; }
h1, h2 { page-break-after: avoid; }
pre { page-break-inside: avoid; }
.toc { page-break-after: always; }
}
</style>
</head>
<body>
<h1 id="build-your-own-mcp-server-with-auth-billing-in-30-minutes">Build Your Own MCP Server With Auth + Billing — In 30 Minutes</h1>
<p><strong>A no-fluff walkthrough of shipping a production MCP server on Cloudflare's free tier.</strong> Every code sample below is real, tested, and runs as-is. The finished server can take real money via Stripe and serve thousands of free-tier users without your bill leaving the noise floor.</p>
<p>By Lucas Kempe · Author of <a href="https://ask-meridian.uk">ask-meridian.uk</a> (live MCP server in the official MCP Registry, Pro tier $29/mo, ~3 KB stdio shim).</p>
<p>Version 1.0 · 60+ pages · Code under MIT · Guide for personal + commercial use. <strong>No reselling the PDF.</strong> No re-publishing as a course or blog post.</p>
<hr>
<h2 id="who-this-is-for">Who this is for</h2>
<p>You already shipped a <code>Hello, world</code> MCP server with <code>npx create-mcp</code>. You realized:</p>
<ul>
<li>You can't actually charge for it</li>
<li>Anyone can drain your LLM bill in an afternoon</li>
<li>Self-hosting it on Render/Fly is overkill and slow</li>
<li>The MCP Registry won't accept a localhost URL</li>
</ul>
<p>This guide walks you from "demo MCP" to "MCP server billed via Stripe, served from Cloudflare's free tier, listed on the official MCP Registry, with proper auth and per-user quotas." Total time on the keyboard: ~30 minutes if you follow it linearly. Total infrastructure cost: $0 until you cross 100k requests/month.</p>
<h2 id="who-this-is-not-for">Who this is NOT for</h2>
<ul>
<li>"I want to learn to code" — you should already know JavaScript/TypeScript.</li>
<li>"I want a one-click SaaS" — this is a guide, you do the typing.</li>
<li>"I want a different framework" — everything here is Cloudflare Workers + Stripe + KV. You can port it later but the stack is opinionated.</li>
</ul>
<h2 id="what-s-in-the-box">What's in the box</h2>
<pre><code class="lang-"> 1. What MCP actually is (the shortest possible explanation)
2. The architecture you're going to ship
3. Cloudflare account setup + first deploy (5 min)
4. KV namespace + per-IP rate limiting (5 min)
5. Stripe account + the $29/mo Pro tier (10 min)
6. API-key issuance via Stripe webhook (5 min)
7. The MCP stdio shim — npm-publishable (5 min)
8. Submitting to the official MCP Registry
9. Production gotchas (cold starts, KV propagation, webhook retries)
10. Adding LLM calls (Anthropic, OpenAI, Workers AI, Groq) cost-safely
11. Bonus: SSE streaming for live progress events
12. Bonus: Vectorize-backed result caching</code></pre>
<p>Every chapter has working code in <code>examples/</code>. The <code>cf-workers-mcp/</code> example in the repo is the public sample (free); the <code>cf-workers-mcp-full/</code> version shipped to buyers has the complete Stripe wiring + auth flow.</p>
<hr>
<div class="toc"><h2>Contents</h2><ol><li><a href="#1-what-mcp-actually-is-the-shortest-possible-explanation">1. What MCP actually is (the shortest possible explanation)</a></li><li><a href="#2-the-architecture-you-re-going-to-ship">2. The architecture you're going to ship</a></li><li><a href="#3-cloudflare-account-setup-first-deploy-5-min">3. Cloudflare account setup + first deploy (5 min)</a></li><li><a href="#4-kv-namespace-per-ip-rate-limiting-5-min">4. KV namespace + per-IP rate limiting (5 min)</a></li><li><a href="#5-stripe-account-the-29-mo-pro-tier-10-min">5. Stripe account + the $29/mo Pro tier (10 min)</a></li><li><a href="#6-api-key-issuance-via-stripe-webhook-5-min">6. API-key issuance via Stripe webhook (5 min)</a></li><li><a href="#7-the-mcp-stdio-shim-npm-publishable-5-min">7. The MCP stdio shim — npm-publishable (5 min)</a></li><li><a href="#8-submitting-to-the-official-mcp-registry">8. Submitting to the official MCP Registry</a></li><li><a href="#9-production-gotchas">9. Production gotchas</a></li><li><a href="#10-adding-llm-calls-cost-safely">10. Adding LLM calls cost-safely</a></li><li><a href="#11-bonus-sse-streaming-for-live-progress-events">11. Bonus: SSE streaming for live progress events</a></li><li><a href="#12-bonus-vectorize-backed-result-caching">12. Bonus: Vectorize-backed result caching</a></li></ol></div>
<h1 id="1-what-mcp-actually-is-the-shortest-possible-explanation">1. What MCP actually is (the shortest possible explanation)</h1>
<p>MCP is a JSON-RPC over stdio (or HTTP) protocol that lets a client like Claude Code, Cursor, Windsurf, or Cline call <strong>tools</strong> on a server you control. That's it.</p>
<p>A tool is a function with:</p>
<ul>
<li>a name</li>
<li>a JSON-Schema input</li>
<li>a string output</li>
</ul>
<p>The client lists tools (<code>tools/list</code>), the user (or the agent) decides one should fire, the client invokes it (<code>tools/call</code>), the server runs it and returns text. Everything else — auth, billing, observability, your business logic — is your problem.</p>
<h2 id="why-two-transports">Why two transports</h2>
<table>
<thead><tr><th>Transport</th><th>When to use</th><th>What this guide ships</th></tr></thead>
<tbody>
<tr><td><strong>stdio</strong></td><td>A small wrapper that runs on the user's laptop and talks to your real backend over HTTPS. Most MCP clients prefer this — no CORS, no auth headaches, no certs.</td><td>✅ Yes, as the <strong>shim</strong></td></tr>
<tr><td><strong>HTTP / SSE</strong></td><td>A streamed bidirectional channel. Some clients support it, many don't yet. Higher friction.</td><td>❌ Not in v1.0 (covered briefly in chapter 11)</td></tr>
</tbody>
</table>
<p>The pattern that works in 2026 is <strong>fat backend + thin shim</strong>:</p>
<pre><code class="lang-"> Claude Code
│ stdio JSON-RPC
▼
┌──────────────────────────┐
│ npm-installable shim │ ~5 KB JavaScript, runs on user laptop
│ (e.g. byo-mcp-foo-shim) │ forwards every tools/call over HTTPS
└────────────┬─────────────┘
│ HTTPS POST
▼
┌──────────────────────────┐
│ your Cloudflare Worker │ real auth, real billing, real LLM calls
│ (or Pages Function) │
└──────────────────────────┘</code></pre>
<p>Why split? Three reasons:</p>
<p>1. <strong>The user installs once.</strong> The shim is ~5 KB; updating it is a <code>npm update</code> they never do. Your real code lives on the server, redeploys instantly without anyone restarting their laptop. 2. <strong>Secrets stay on the server.</strong> API keys for upstream LLMs (Anthropic, Groq, etc.) never touch the user's disk. 3. <strong>Centralized observability.</strong> Every call goes through your Worker. You see usage. You can rate-limit. You can ship a feature flag.</p>
<p>This guide ships both halves. The shim is a single <code>shim.mjs</code> file. The Worker is a <code>src/index.ts</code> file. Both deploy with one command.</p>
<hr>
<h1 id="2-the-architecture-you-re-going-to-ship">2. The architecture you're going to ship</h1>
<pre><code class="lang-"> ┌──────────────────────────────┐
│ Claude Code / Cursor / Cline │
│ Windsurf / any MCP client │
└─────────────┬────────────────┘
│ stdio JSON-RPC
▼
┌──────────────────────────────┐
│ npm-installable shim │ ~5 KB
│ byo-mcp-foo-shim │ user runs
│ (forwards every call over │ on their
│ HTTPS to your backend) │ laptop
└─────────────┬────────────────┘
│ HTTPS POST
│ Authorization: Bearer …
▼
┌──────────────────────────────────────────────────────────┐
│ your-mcp.example.com (Cloudflare DNS, proxied) │
└────────────────────────┬─────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ Cloudflare Worker / Pages Function │
│ - reads bearer token, validates against KV │
│ - per-IP free tier rate limit (KV counter) │
│ - forwards to LLM (Anthropic / Workers AI / Groq) │
│ - emits Stripe webhook for new subscriptions │
└────────┬──────────────────────────┬──────────────────────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────────────────────┐
│ Cloudflare KV │ │ Stripe │
│ key:HASH │ │ Checkout · Customer Portal │
│ free:IP:DAY │ │ Webhooks → /api/stripe/webhook │
│ session:SID │ └────────────────────────────────┘
└────────────────┘</code></pre>
<p>Three boxes, all on free or near-free tiers:</p>
<table>
<thead><tr><th>Box</th><th>Provider</th><th>Free-tier ceiling</th></tr></thead>
<tbody>
<tr><td>Static + Functions</td><td>Cloudflare Pages + Workers</td><td>100k requests/day, 10ms CPU each</td></tr>
<tr><td>Key + quota store</td><td>Cloudflare KV</td><td>100k reads/day, 1k writes/day</td></tr>
<tr><td>Payments</td><td>Stripe</td><td>2.9% + 30¢ per charge (no fixed monthly cost)</td></tr>
</tbody>
</table>
<p>For an MCP server with 100 paying users at $29/mo, this stack costs you literally $0 in hosting and earns you $2,900/mo. The economics break favourably long before you'd ever need to think about a different host.</p>
<h2 id="what-a-request-looks-like-end-to-end">What a request looks like end to end</h2>
<p>1. User in Claude Code says "use my-tool to do X" 2. Claude Code resolves <code>my-tool</code> → the npm-installed shim, which is already speaking JSON-RPC over its own stdin/stdout 3. Shim wraps the call as <code>{ "method": "tools/call", "params": { ... } }</code> and sends to your Worker as an HTTPS POST with the user's API key in the Authorization header 4. Your Worker: - validates the bearer token against <code>key:HASH</code> in KV - increments their monthly counter (<code>monthly:HASH:YYYYMM</code>) - if free tier (no bearer), checks <code>free:IP:DAY < limit</code> - calls upstream LLM (or computes locally) - returns JSON 5. Shim wraps the JSON response back into the JSON-RPC format Claude Code expects 6. Claude Code shows the result to the user</p>
<p>The whole round trip is typically 100-300 ms when the LLM is local (Groq is ~5 s for a real generation, Anthropic ~10-30 s, Workers AI ~30-60 s).</p>
<h2 id="why-each-piece-is-where-it-is">Why each piece is where it is</h2>
<ul>
<li><strong>Shim on user's laptop</strong> — because MCP clients don't speak HTTPS directly; they expect JSON-RPC over stdio. The shim is the translation layer.</li>
<li><strong>Worker</strong> — because edge isolates start in <5 ms and there's no cold-start fee. You can have 1000s of users in different time zones without spinning up servers.</li>
<li><strong>KV</strong> — because we just need O(1) lookups by key (api_key → user_record), not relational queries. KV is also free up to a generous threshold.</li>
<li><strong>Stripe</strong> — because they handle PCI compliance, dispute handling, tax in 30+ jurisdictions, and currency conversion. Charging $29 to credit cards yourself is a worse use of your time than building features.</li>
</ul>
<h1 id="3-cloudflare-account-setup-first-deploy-5-min">3. Cloudflare account setup + first deploy (5 min)</h1>
<h2 id="sign-up">Sign up</h2>
<p><a href="https://dash.cloudflare.com/sign-up">dash.cloudflare.com/sign-up</a> — free, no credit card required, takes ~60 seconds.</p>
<p>After signup, you land on the dashboard. Two things to note:</p>
<ul>
<li>Your <strong>account ID</strong> lives in the right sidebar (32-char hex). You'll need it for <code>wrangler.toml</code> and any API calls.</li>
<li>The <strong>Workers & Pages</strong> section in the left nav is where everything you build today lives.</li>
</ul>
<h2 id="install-wrangler">Install wrangler</h2>
<pre><code class="lang-bash">npm install -g wrangler
wrangler login</code></pre>
<p><code>wrangler login</code> opens your browser, you click "Authorize Wrangler". Done. Wrangler is the CLI that deploys + manages your Worker.</p>
<h2 id="first-deploy">First deploy</h2>
<p>From the repo root:</p>
<pre><code class="lang-bash">cd examples/cf-workers-mcp
npm install
wrangler deploy</code></pre>
<p>You'll see something like:</p>
<pre><code class="lang-"> ⛅️ wrangler 3.90.0
Total Upload: 8.43 KiB / gzip: 3.12 KiB
Uploaded cf-workers-mcp-sample (1.42 sec)
Deployed cf-workers-mcp-sample triggers (0.18 sec)
https://cf-workers-mcp-sample.<your-subdomain>.workers.dev
Current Version ID: …</code></pre>
<p>That URL is <strong>live, on the public internet, with TLS, behind Cloudflare's edge network, in 200+ cities, in under 2 seconds.</strong></p>
<p>Test it:</p>
<pre><code class="lang-bash">curl https://cf-workers-mcp-sample.<your-subdomain>.workers.dev/api/echo?msg=hi
{
"msg": "hi",
"your_ip": "...",
"served_by": "cf-workers-mcp-sample"
}</code></pre>
<h2 id="custom-domain-optional-recommended">Custom domain (optional, recommended)</h2>
<p>Skip this if you don't have a domain yet. With one:</p>
<p>1. Add the domain to Cloudflare (Add Site → enter domain → free tier). 2. Update your registrar's nameservers to the two Cloudflare gives you. 3. Wait ~5-30 minutes for propagation. 4. In Workers & Pages → your worker → Triggers → Custom Domain → add <code>your-mcp.example.com</code>.</p>
<p>Cloudflare auto-provisions the TLS cert. No certbot, no renew jobs.</p>
<h2 id="local-dev">Local dev</h2>
<pre><code class="lang-bash">wrangler dev</code></pre>
<p>Spawns a local Worker on http://localhost:8787 with the same runtime as production. Code changes hot-reload. KV operations get persisted to a local SQLite DB so you don't share state with prod.</p>
<h2 id="what-you-just-shipped-vs-what-s-missing">What you just shipped vs what's missing</h2>
<p>You now have a public HTTPS endpoint that:</p>
<ul>
<li>✅ runs in Cloudflare's edge runtime</li>
<li>✅ handles HTTP, JSON, CORS</li>
<li>✅ has 100k free requests/day</li>
<li>❌ has no persistence (no API keys, no quotas)</li>
<li>❌ has no authentication</li>
<li>❌ takes no money</li>
</ul>
<p>The next two chapters fix the first two of those. Chapter 5 wires Stripe.</p>
<h1 id="4-kv-namespace-per-ip-rate-limiting-5-min">4. KV namespace + per-IP rate limiting (5 min)</h1>
<p>KV is Cloudflare's key-value store. Eventually consistent, globally distributed, free up to 100k reads/day + 1k writes/day. Perfect for our use case: looking up "is this API key valid" and "how many free calls did this IP make today".</p>
<h2 id="create-the-namespace">Create the namespace</h2>
<pre><code class="lang-bash">cd examples/cf-workers-mcp
wrangler kv namespace create MCP_KV</code></pre>
<p>Returns:</p>
<pre><code class="lang-"> 🌀 Creating namespace with title "cf-workers-mcp-sample-MCP_KV"
✨ Success!
Add the following to your configuration file:
[[kv_namespaces]]
binding = "MCP_KV"
id = "abc123def456…"</code></pre>
<p>Paste the <code>id</code> into the <code>[[kv_namespaces]]</code> section of <code>wrangler.toml</code>. Repeat the command with <code>--preview</code> to get a separate ID for <code>wrangler dev</code>:</p>
<pre><code class="lang-bash">wrangler kv namespace create MCP_KV --preview</code></pre>
<p>Paste the returned <code>preview_id</code> into the same section. Now <code>wrangler dev</code> and <code>wrangler deploy</code> both have isolated KV.</p>
<h2 id="how-the-rate-limiter-works">How the rate limiter works</h2>
<p>The pattern is:</p>
<pre><code class="lang-typescript">const ip = request.headers.get('cf-connecting-ip') || 'unknown'
const dkey = `free:${ip}:${new Date().toISOString().slice(0, 10)}`
// ↑ namespace
// ↑ identifier
// ↑ partition by date so old keys auto-expire
const used = parseInt((await env.MCP_KV.get(dkey)) || '0', 10)
if (used >= LIMIT) return json({ error: 'free tier exhausted' }, { status: 429 })
await env.MCP_KV.put(dkey, String(used + 1), { expirationTtl: 90000 })
// ↑ 25 hours; KV evicts after</code></pre>
<p>Three things to know:</p>
<p>1. <strong>Atomicity</strong> — KV doesn't support atomic increments. Two concurrent requests can both read <code>used=4</code>, both write <code>5</code>, and you've under-counted by one. At 5/day per IP, this is fine. At 5/sec it isn't — for that you need Durable Objects (covered briefly in chapter 9).</p>
<p>2. <strong>Eventual consistency</strong> — a <code>put</code> from one edge can take ~60s to be visible at another. Don't use KV for "did the user just pay yet" — use the Stripe webhook (chapter 6) directly.</p>
<p>3. <strong>TTL is in seconds</strong> — <code>expirationTtl: 90000</code> ≈ 25 hours, which means yesterday's <code>free:IP:DATE</code> key auto-expires before tomorrow uses it.</p>
<h2 id="what-good-ip-limits-look-like">What good IP limits look like</h2>
<pre><code class="lang-"> plan what they get daily IP cap (anonymous)
──────── ──────────────────────────── ────────────────────────
Free no API key, just hit the URL 5-10 requests/day per IP
Pro $29 API key, monthly cap 10,000 requests/month
Team $99 API key, monthly cap 100,000 requests/month</code></pre>
<p>The 5-10/day for anonymous is the load-bearing piece. It says: "you can try the service free, you can build a demo, but if your project gets serious you have to pay." Most users hit the cap once and either upgrade or accept they have to wait until tomorrow.</p>
<p>Don't go below 3/day or you frustrate honest tire-kickers. Don't go above 20 or you'll get scraped by people building wrappers around your free tier.</p>
<h2 id="what-about-ipv6">What about IPv6?</h2>
<p>Cloudflare gives every request an IP via <code>cf-connecting-ip</code>. For IPv6, residential ISPs hand out /64 prefixes — the user's host bits change every few hours. Per-IPv6-address counting under-counts.</p>
<p>The fix is to compute the /64 prefix and use that as the rate-limit key:</p>
<pre><code class="lang-typescript">function ipKey(ip: string): string {
if (!ip.includes(':')) return ip // IPv4 — use as-is
// IPv6 — use the /64 prefix (first 4 hex groups)
const parts = ip.split('::')
if (parts.length > 2) return ip
const head = parts[0] ? parts[0].split(':') : []
const tail = parts[1] ? parts[1].split(':') : []
const fill = 8 - head.length - tail.length
if (fill < 0) return ip
const groups = [...head, ...Array(fill).fill('0'), ...tail]
return groups.slice(0, 4).map(g => g.padStart(4, '0').toLowerCase()).join(':')
}</code></pre>
<p>The full version of this is in <code>examples/cf-workers-mcp-full/src/_ip.ts</code> (buyers' bundle). It handles malformed input + uppercase + compressed forms.</p>
<h2 id="smoke-test">Smoke test</h2>
<pre><code class="lang-bash"># Fresh IP, fresh day → should succeed N times then 429
for i in {1..12}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://your-worker.workers.dev/api/protected \
-H "content-type: application/json" -d '{"msg":"hi"}'
done
# Expected: 200 200 200 200 200 200 200 200 200 200 429 429</code></pre>
<p>(default in the sample is 10/day per IP; tweak in <code>wrangler.toml</code> → <code>FREE_TIER_DAILY_PER_IP</code>.)</p>
<p>You now have a working free tier. Next we add Stripe so people can buy past it.</p>
<h1 id="5-stripe-account-the-29-mo-pro-tier-10-min">5. Stripe account + the $29/mo Pro tier (10 min)</h1>
<h2 id="sign-up-activate">Sign up + activate</h2>
<p><a href="https://stripe.com/register">stripe.com/register</a>. Sign up with the email you want to receive money at. After confirming, Stripe asks for business details — you can defer this in <strong>Test mode</strong> but you can't accept real money until you Activate (which involves SSN/tax-ID/business address; takes ~5 minutes).</p>
<p>For this chapter, stay in Test mode. We'll flip to Live mode at the end.</p>
<h2 id="create-the-product-price">Create the product + price</h2>
<p>Dashboard → <strong>Products</strong> → <strong>+ Add Product</strong>.</p>
<pre><code class="lang-"> Name: Pro Tier
Description: 10,000 tool calls per month for [your service name]
Image: (optional, but increases checkout conversion ~5%)
Pricing:
Recurring · Monthly · USD $29.00</code></pre>
<p>Click <strong>Save</strong>. On the product page you'll see a <code>price_…</code> ID — copy it. That's the value you'll set as the env var <code>STRIPE_PRICE_PRO</code>.</p>
<p>Repeat for any other tiers (Team $99, etc.).</p>
<h2 id="get-the-api-keys">Get the API keys</h2>
<p>Dashboard → <strong>Developers</strong> → <strong>API keys</strong>.</p>
<p>You need two:</p>
<table>
<thead><tr><th>Key</th><th>Format</th><th>Where it goes</th></tr></thead>
<tbody>
<tr><td><strong>Secret key</strong></td><td><code>sk_test_…</code> (test mode) → <code>sk_live_…</code> (live)</td><td>Worker secret: <code>wrangler secret put STRIPE_SECRET_KEY</code></td></tr>
<tr><td><strong>Publishable key</strong></td><td><code>pk_test_…</code> → <code>pk_live_…</code></td><td>Front-end JS, can be in the source</td></tr>
</tbody>
</table>
<p>Set the secret on your Worker:</p>
<pre><code class="lang-bash">wrangler secret put STRIPE_SECRET_KEY
# (paste the sk_test_… value when prompted)
wrangler secret put STRIPE_PRICE_PRO
# (paste the price_… ID)</code></pre>
<p>Verify they're set:</p>
<pre><code class="lang-bash">wrangler secret list
# [{"name":"STRIPE_SECRET_KEY","type":"secret_text"},
# {"name":"STRIPE_PRICE_PRO","type":"secret_text"}]</code></pre>
<h2 id="the-checkout-session-endpoint">The Checkout Session endpoint</h2>
<p>Add this to <code>src/index.ts</code>:</p>
<pre><code class="lang-typescript">if (url.pathname === '/api/stripe/checkout' && request.method === 'POST') {
const stripe = new Stripe(env.STRIPE_SECRET_KEY) // see below
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: env.STRIPE_PRICE_PRO, quantity: 1 }],
success_url: 'https://your-mcp.example.com/api/stripe/claim?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://your-mcp.example.com/cancel',
metadata: { plan: 'pro' },
})
return json({ url: session.url })
}</code></pre>
<p>There are two ways to call Stripe from a Worker:</p>
<p><strong>Option A: the official <code>stripe</code> npm package.</strong> ~700 KB minified, eats into your Worker bundle limit (1 MB free, 10 MB paid). Heaviest path, but identical to a Node app.</p>
<p><strong>Option B: raw <code>fetch</code> to Stripe's REST API.</strong> ~50 lines of code, no dependency. Recommended for Workers.</p>
<p>The buyer bundle ships option B. Here's the gist:</p>
<pre><code class="lang-typescript">async function stripeAPI(env: Env, path: string, body?: Record<string, string>) {
const r = await fetch(`https://api.stripe.com/v1${path}`, {
method: body ? 'POST' : 'GET',
headers: {
'Authorization': `Bearer ${env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body ? new URLSearchParams(body).toString() : undefined,
})
if (!r.ok) throw new Error(`Stripe ${r.status}: ${await r.text()}`)
return r.json()
}
// Create the checkout session — Stripe wants square-bracket-notation for
// arrays, so we manually flatten line_items
const session = await stripeAPI(env, '/checkout/sessions', {
mode: 'subscription',
'line_items[0][price]': env.STRIPE_PRICE_PRO,
'line_items[0][quantity]': '1',
success_url: `${BASE}/api/stripe/claim?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE}/cancel`,
'metadata[plan]': 'pro',
})
return json({ url: session.url })</code></pre>
<h2 id="trigger-checkout-from-your-landing-page">Trigger checkout from your landing page</h2>
<pre><code class="lang-html"><button id="buy" data-plan="pro">Subscribe — $29/mo</button>
<script>
document.getElementById('buy').addEventListener('click', async () => {
const r = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ plan: 'pro' }),
})
const { url } = await r.json()
location.href = url
})
</script></code></pre>
<p>Click → Stripe checkout opens (it's their hosted page, you don't ship the form) → user pays → Stripe redirects to your <code>success_url</code>.</p>
<p>What happens at <code>success_url</code> is what chapter 6 covers — that's where you issue the API key.</p>
<h2 id="test-it-end-to-end">Test it end to end</h2>
<p>In Test mode, use card <code>4242 4242 4242 4242</code>, any future expiry, any CVC. Click pay. You'll land on your <code>success_url?session_id=cs_test_…</code>.</p>
<p>If you didn't yet build the claim endpoint, you'll get a 404 — that's expected, fix it next chapter.</p>
<h1 id="6-api-key-issuance-via-stripe-webhook-5-min">6. API-key issuance via Stripe webhook (5 min)</h1>
<p>A buyer just paid. We need to:</p>
<p>1. Generate an API key, e.g. <code>mrd_live_abc123…</code> 2. Store the key's hash + the buyer's plan + their Stripe customer ID in KV 3. Show the plain-text key to the buyer <strong>once</strong> (they save it) 4. From now on, every request the buyer makes carries the key in <code>Authorization: Bearer …</code> and we look up the hash in KV</p>
<p>The flow has two parts: the <strong>webhook</strong> (Stripe → us, async, can retry) and the <strong>claim endpoint</strong> (buyer → us, sync, fires after redirect).</p>
<h2 id="generate-keys">Generate keys</h2>
<pre><code class="lang-typescript">function generateKey(): string {
// 32 random bytes = 256 bits. Web Crypto in Workers gives us this for free.
const bytes = crypto.getRandomValues(new Uint8Array(32))
// base64url is URL-safe; use it so users can paste keys into env vars
// without quoting issues.
const b64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
return `mrd_live_${b64}`
}
async function hashKey(key: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key))
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
}</code></pre>
<p>Why hash? Because if your KV gets dumped (insider threat, misconfiguration, SDK bug), the leaked data shouldn't include working tokens. We only ever store the SHA-256.</p>
<h2 id="the-webhook-the-source-of-truth">The webhook (the source of truth)</h2>
<p>Stripe POSTs to whatever URL you configure under <strong>Developers → Webhooks → Add endpoint</strong>. Set the URL to <code>https://your-mcp.example.com/api/stripe/webhook</code> and select event <code>checkout.session.completed</code> (and optionally <code>customer.subscription.deleted</code> for cancellations).</p>
<p>Save and copy the <strong>signing secret</strong> — <code>whsec_…</code>. Store as Worker secret:</p>
<pre><code class="lang-bash">wrangler secret put STRIPE_WEBHOOK_SECRET</code></pre>
<p>The handler:</p>
<pre><code class="lang-typescript">if (url.pathname === '/api/stripe/webhook' && request.method === 'POST') {
const sig = request.headers.get('stripe-signature') || ''
const body = await request.text()
if (!await verifySig(body, sig, env.STRIPE_WEBHOOK_SECRET)) {
return new Response('bad signature', { status: 400 })
}
const event = JSON.parse(body)
if (event.type === 'checkout.session.completed') {
const s = event.data.object
const plan = s.metadata?.plan || 'pro'
const key = generateKey()
const hash = await hashKey(key)
await env.MCP_KV.put(`key:${hash}`, JSON.stringify({
plan,
customer_id: s.customer,
monthly_limit: plan === 'team' ? 100000 : 10000,
created_at: Date.now(),
}))
// Stash the plain key under the session ID, short TTL — only
// the buyer's redirect needs it
await env.MCP_KV.put(`session:${s.id}`, key, { expirationTtl: 1800 })
}
return new Response('ok')
}</code></pre>
<p>Verify the signature properly (Stripe uses HMAC-SHA-256 with a timestamp + body). The buyer bundle has a tested 25-line <code>verifySig</code> implementation under <code>examples/cf-workers-mcp-full/src/_stripe.ts</code>.</p>
<h2 id="the-claim-endpoint-the-user-facing-page">The claim endpoint (the user-facing page)</h2>
<p>After paying, the user lands on:</p>
<pre><code class="lang-">https://your-mcp.example.com/api/stripe/claim?session_id=cs_test_…</code></pre>
<p>Handler:</p>
<pre><code class="lang-typescript">if (url.pathname === '/api/stripe/claim') {
const sid = url.searchParams.get('session_id') || ''
const key = await env.MCP_KV.get(`session:${sid}`)
if (!key) return json({ error: 'session not found or already claimed' }, { status: 404 })
await env.MCP_KV.delete(`session:${sid}`)
// Return an HTML page (so the user sees + copies the key in their browser)
return new Response(`
<!DOCTYPE html><meta charset="utf-8">
<h1>Welcome to Pro 🎉</h1>
<p>Your API key (shown only once — save it now):</p>
<code style="display:block;padding:12px;background:#eee">${key}</code>
<p>Set it in your shim:
<code>BYO_MCP_API_KEY=${key}</code></p>
`, { headers: { 'content-type': 'text/html' }})
}</code></pre>
<p>That's the full happy path. The buyer pays, gets a key shown once, copies it into their config, and from then on every shim → backend call carries the bearer.</p>
<h2 id="validating-keys-on-every-protected-request">Validating keys on every protected request</h2>
<p>Update <code>/api/protected</code>:</p>
<pre><code class="lang-typescript">const auth = request.headers.get('authorization') || ''
if (!auth.startsWith('Bearer ')) return json({ error: 'auth required' }, { status: 401 })
const key = auth.slice(7).trim()
const hash = await hashKey(key)
const rec = await env.MCP_KV.get(`key:${hash}`, 'json') as KeyRecord | null
if (!rec) return json({ error: 'invalid key' }, { status: 401 })
// Monthly quota
const monthKey = `monthly:${hash}:${new Date().toISOString().slice(0, 7).replace('-','')}`
const used = parseInt(await env.MCP_KV.get(monthKey) || '0', 10)
if (used >= rec.monthly_limit) {
return json({ error: 'monthly quota exhausted' }, { status: 429 })
}
await env.MCP_KV.put(monthKey, String(used + 1), { expirationTtl: 32 * 86400 })</code></pre>
<p>Headers worth setting on success so the user knows where they stand:</p>
<pre><code class="lang-typescript">return json(result, {
headers: {
'x-meridian-plan': rec.plan,
'x-meridian-calls-remaining': String(rec.monthly_limit - used - 1),
},
})</code></pre>
<h2 id="what-you-have-now">What you have now</h2>
<ul>
<li>Real users can pay you real money with a real credit card</li>
<li>They get a key shown once, hashed at rest</li>
<li>Every API call validates + counts down from their monthly quota</li>
<li>All on Cloudflare's free tier</li>
<li>Total code added today: ~150 lines</li>
</ul>
<h1 id="7-the-mcp-stdio-shim-npm-publishable-5-min">7. The MCP stdio shim — npm-publishable (5 min)</h1>
<p>The shim is what users install. Goals:</p>
<ul>
<li>≤ 5 KB of JavaScript, zero dependencies (so it's <code>npx</code>-friendly)</li>
<li>Speak MCP JSON-RPC on stdin/stdout</li>
<li>Forward every <code>tools/call</code> to your HTTPS backend with the user's bearer key</li>
<li>Surface upstream errors in a way Claude Code can render</li>
</ul>
<h2 id="minimal-shim">Minimal shim</h2>
<p>Already shipped under <code>examples/stdio-shim/shim.mjs</code>. The bones:</p>
<pre><code class="lang-javascript">#!/usr/bin/env node
import readline from 'node:readline'
const API_URL = process.env.BYO_MCP_API_URL || 'http://localhost:8787'
const API_KEY = process.env.BYO_MCP_API_KEY || ''
const TOOLS = [{
name: 'echo',
description: 'Echo a message back. Demo tool — replace with your own.',
inputSchema: {
type: 'object',
properties: { msg: { type: 'string' } },
required: ['msg'],
},
}]
const rl = readline.createInterface({ input: process.stdin })
const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n')
rl.on('line', async (line) => {
let req
try { req = JSON.parse(line) } catch { return }
if (req.method === 'initialize') {
return out({ jsonrpc: '2.0', id: req.id, result: {
protocolVersion: '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'byo-mcp-shim', version: '0.1.0' },
}})
}
if (req.method === 'tools/list') {
return out({ jsonrpc: '2.0', id: req.id, result: { tools: TOOLS } })
}
if (req.method === 'tools/call' && req.params?.name === 'echo') {
const headers = { 'content-type': 'application/json' }
if (API_KEY) headers.authorization = `Bearer ${API_KEY}`
const r = await fetch(`${API_URL}/api/protected`, {
method: 'POST', headers,
body: JSON.stringify(req.params.arguments),
})
const data = await r.json()
if (!r.ok) return out({ jsonrpc: '2.0', id: req.id, error: { code: -32000, message: data.error }})
return out({ jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: JSON.stringify(data) }]}})
}
})</code></pre>
<h2 id="how-users-install">How users install</h2>
<p>Once you publish to npm, users add this to their MCP client config:</p>
<pre><code class="lang-json">// Claude Code: ~/.claude/claude_desktop_config.json
{
"mcpServers": {
"your-server-name": {
"command": "npx",
"args": ["-y", "byo-mcp-foo-shim"],
"env": {
"BYO_MCP_API_URL": "https://your-mcp.example.com",
"BYO_MCP_API_KEY": "mrd_live_…"
}
}
}
}</code></pre>
<p><code>npx -y</code> ensures the shim is fetched from npm if not already cached. The <code>-y</code> skips the install prompt — important for first-run UX.</p>
<p>For Cursor: <code>~/.cursor/mcp.json</code>, same shape. For Cline: settings → MCP.</p>
<h2 id="publishing-to-npm">Publishing to npm</h2>
<pre><code class="lang-bash">cd examples/stdio-shim
npm login
npm publish --access public</code></pre>
<p>If your name has a scope (<code>@yourname/foo</code>), <code>--access public</code> is required for free accounts. Otherwise it's not.</p>
<p>Bump the version on every change:</p>
<pre><code class="lang-bash">npm version patch # 0.1.0 → 0.1.1
npm publish</code></pre>
<p>Once published, your users do <code>npx -y your-package-name</code> and the shim is fetched + cached on first run.</p>
<h2 id="make-multiple-tools-work">Make multiple tools work</h2>
<p>The simplest pattern: a static array of <code>TOOLS</code>, each with its own handler.</p>
<pre><code class="lang-javascript">const TOOLS = [
{ name: 'echo', description: '...', inputSchema: {...} },
{ name: 'search', description: '...', inputSchema: {...} },
{ name: 'render', description: '...', inputSchema: {...} },
]
const handlers = {
echo: async (args) => callBackend('/api/echo', args),
search: async (args) => callBackend('/api/search', args),
render: async (args) => callBackend('/api/render', args),
}
if (req.method === 'tools/call') {
const handler = handlers[req.params.name]
if (!handler) return out({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'unknown tool' }})
try {
const data = await handler(req.params.arguments)
return out({ jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: JSON.stringify(data) }]}})
} catch (e) {
return out({ jsonrpc: '2.0', id: req.id, error: { code: -32000, message: e.message }})
}
}</code></pre>
<h2 id="better-dynamic-tool-list">Better: dynamic tool list</h2>
<p>If your service evolves, you don't want users to <code>npm update</code> every time. Have the shim fetch the tool list from your backend:</p>
<pre><code class="lang-javascript">let TOOLS = []
async function refreshTools() {
const r = await fetch(`${API_URL}/api/tools`, { headers: API_KEY ? { authorization: `Bearer ${API_KEY}` } : {} })
if (r.ok) TOOLS = await r.json()
}
// Refresh on init
if (req.method === 'initialize') {
await refreshTools()
return out({ /* ... */ })
}</code></pre>
<p>Now you ship a new tool just by updating the backend — no shim release.</p>
<h2 id="backwards-compatibility">Backwards compatibility</h2>
<p>When you change a tool's input schema, <strong>don't break existing users</strong>. Pin versions:</p>
<pre><code class="lang-javascript">const TOOLS = [
{ name: 'echo', inputSchema: {...} }, // v1
{ name: 'echo_v2', inputSchema: {...} }, // v2 with new fields
]</code></pre>
<p>Or use <code>additionalProperties: true</code> in the schema so old shims keep working when you add new fields.</p>
<h1 id="8-submitting-to-the-official-mcp-registry">8. Submitting to the official MCP Registry</h1>
<p>The official Model Context Protocol Registry at <code>registry.modelcontextprotocol.io</code> is the canonical discovery surface for MCP servers — Claude Code, Cursor, etc. will eventually all read from it. Getting listed is a one-time ~10-minute job and produces compounding traffic.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Your server published to npm under a stable name</li>
<li>A <code>server.json</code> file in the <strong>root</strong> of your repo</li>
<li>A GitHub repo (the registry pulls metadata from it)</li>
</ul>
<h2 id="write-server-json">Write <code>server.json</code></h2>
<p>This is the meta-manifest the registry indexes:</p>
<pre><code class="lang-json">{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.YourGitHubUser/your-server-name",
"description": "One sentence, ≤100 chars, that says what the server does.",
"repository": {
"url": "https://github.com/YourGitHubUser/your-repo",
"source": "github"
},
"version": "1.0.0",
"packages": [
{
"registryType": "npm",
"identifier": "your-shim-package-name",
"version": "1.0.0",
"transport": { "type": "stdio" },
"environmentVariables": [
{
"name": "BYO_MCP_API_URL",
"default": "https://your-mcp.example.com",
"description": "The HTTPS backend URL the shim forwards to.",
"required": false
},
{
"name": "BYO_MCP_API_KEY",
"isSecret": true,
"description": "Pro/Team API key. Omit for free tier.",
"required": false
}
]
}
]
}</code></pre>
<p>A few things to know:</p>
<ul>
<li><strong>Description is hard-capped at 100 chars.</strong> Counted server-side; if you go over, the publish call returns "expected length <= 100".</li>
<li><strong>The <code>name</code> field uses reverse-DNS style.</strong> <code>io.github.LuuOW/meridian-skills</code> is the format. The first segment is your namespace, the second is the slug.</li>
<li><strong>Versions must be SemVer.</strong> <code>1.0.0</code>, not <code>v1.0.0</code> or <code>1.0</code>.</li>
<li><strong>Don't include a <code>remotes</code> block</strong> unless you actually expose an HTTP MCP endpoint on the public internet (most stdio servers don't — the shim is the user-facing entry point, not the HTTP backend).</li>
</ul>
<h2 id="install-the-publisher">Install the publisher</h2>
<pre><code class="lang-bash">npm install -g @modelcontextprotocol/mcp-publisher
mcp-publisher login github # opens browser, authorizes
mcp-publisher validate # lints server.json
mcp-publisher publish # POSTs to registry.modelcontextprotocol.io</code></pre>
<p><code>validate</code> will fail loudly on schema violations. Common ones:</p>
<ul>
<li>description >100 chars</li>
<li>name not following the reverse-DNS pattern</li>
<li>version not SemVer</li>
<li><code>repository.source</code> not in {github, gitlab}</li>
</ul>
<h2 id="after-publishing">After publishing</h2>
<p>Within a few minutes:</p>
<pre><code class="lang-bash">curl https://registry.modelcontextprotocol.io/v0/servers?search=your-server | jq '.servers'</code></pre>
<p>Should return your entry with <code>isLatest: true</code>. From here, MCP clients that support registry discovery will surface your server in their search UIs.</p>
<h2 id="cross-list-on-awesome-mcp-servers">Cross-list on awesome-mcp-servers</h2>
<p><a href="https://github.com/punkpeye/awesome-mcp-servers">github.com/punkpeye/awesome-mcp-servers</a> is a community-maintained README that indexes thousands of servers by category. Open a PR adding your row in the appropriate section:</p>
<pre><code class="lang-markdown">- [your-server-name](https://github.com/yourname/your-repo) 🤖🤖🤖 — One sentence describing what it does.</code></pre>
<p>The 🤖🤖🤖 emoji marks "agent fast-track" and is reserved for servers explicitly designed to be agent-callable. The PR usually merges within a few days.</p>
<h2 id="cross-list-on-mcp-so">Cross-list on mcp.so</h2>
<p><a href="https://mcp.so">mcp.so</a> is a directory + search engine for MCP servers. There's a submission form at the top of their page. Fewer SEO benefits than the official registry, but they get organic Google traffic.</p>
<h2 id="what-this-gets-you">What this gets you</h2>
<ul>
<li>Your server appears in MCP-aware client search UIs (Claude Code's upcoming registry browser, similar features in Cursor/Cline)</li>
<li>Awesome-list cross-listing gets you GitHub stars from people browsing the README</li>
<li>mcp.so adds long-tail SEO</li>
</ul>
<p>Combined, these are passive distribution channels that work for years after one afternoon of submission.</p>
<h1 id="9-production-gotchas">9. Production gotchas</h1>
<p>Things that look fine in dev and bite in production.</p>
<h2 id="kv-propagation-latency">KV propagation latency</h2>
<p>Cloudflare KV is <strong>eventually consistent</strong>. A <code>put</code> from the IAD edge can take ~60s to be visible at SYD. This breaks two patterns:</p>
<p>1. <strong>"Did the user just pay yet"</strong> — your webhook handler writes <code>key:HASH</code> from one edge; the user's first protected call from another edge sees nothing. Result: brand-new buyer gets a 401.</p>
<p>Fix: in the claim endpoint, return a temporary "session-bound" key that's stored under the local edge's key (using <code>cacheTtl</code>), so the first request immediately after checkout works.</p>
<p>2. <strong>Updating a user's plan</strong> — they upgraded to Team. The first request they make might still be rate-limited under the Pro cap.</p>
<p>Fix: don't read-then-write per request. Read the rec once at request time, accept the small staleness, log it.</p>
<h2 id="worker-cpu-time-limit">Worker CPU time limit</h2>
<p>Free tier: <strong>10 ms CPU per request</strong>. Paid tier: <strong>50 ms</strong>. CPU time = the time spent executing your code, NOT including waits on <code>fetch()</code>, <code>KV.get()</code>, etc. Those are I/O and don't count.</p>
<p>Where you'll hit this:</p>
<ul>
<li>Heavy JSON parsing on big responses (>50 KB)</li>
<li>Crypto: SHA-256 of a small string is fine, hashing 10 MB is not</li>
<li>Loops over thousands of array items</li>
</ul>
<p>What to do:</p>
<ul>
<li>For long compute, use a Worker with <code>unbound</code> CPU limits (paid plan)</li>
<li>For really long compute, use Workers Queues + a separate consumer</li>
<li>For one-off heavy jobs, return immediately and process in the background via <code>ctx.waitUntil(promise)</code></li>
</ul>
<h2 id="bundle-size">Bundle size</h2>
<p>Free Workers cap at <strong>1 MB compressed</strong> bundle. The official <code>stripe</code> npm package is ~700 KB by itself; <code>openai</code> SDK is ~400 KB. Adding both = over the limit.</p>
<p>Fixes:</p>
<ul>
<li>Use raw <code>fetch()</code> to upstream APIs instead of SDKs (it's how the buyer bundle's <code>_stripe.ts</code> works — 25 LoC vs 700 KB)</li>
<li>Tree-shake aggressively (esbuild does this by default)</li>
<li>Move heavy logic to a Worker hosted at <code>unbound</code> plan if you really need the SDKs</li>
</ul>
<h2 id="webhook-retries">Webhook retries</h2>
<p>Stripe retries failed webhooks for up to 3 days at exponential backoff. If your handler returns anything other than 2xx, it WILL retry. Two failure modes:</p>
<p>1. <strong>Non-idempotent handlers</strong> — if processing the same event twice creates two API keys, you're going to have a bad time. Always use the event's <code>id</code> as a dedup key:</p>
<p>``<code>typescript const dedup = await env.MCP_KV.get(</code>webhook:${event.id}<code>) if (dedup) return new Response('already processed') await env.MCP_KV.put(</code>webhook:${event.id}<code>, '1', { expirationTtl: 86400 * 7 }) // ... process </code>``</p>
<p>2. <strong>Synchronous LLM calls in webhook handler</strong> — if you do something slow inside the handler, Stripe might time out (20s limit) and retry. Always: do the small synchronous bookkeeping (write to KV), then <code>ctx.waitUntil()</code> the slow stuff.</p>
<h2 id="cors-in-browser-launched-checkout">CORS in browser-launched checkout</h2>
<p>If your landing page is on <code>your-mcp.example.com</code> and you POST to <code>/api/stripe/checkout</code> on the same origin, no CORS needed. If you embed the checkout button on a different origin, your Worker must:</p>
<pre><code class="lang-typescript">if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'POST, GET, OPTIONS',
'access-control-allow-headers': 'content-type, authorization',
'access-control-max-age': '86400',
}})
}</code></pre>
<h2 id="free-tier-abuse">Free-tier abuse</h2>
<p>People will:</p>
<ul>
<li>Cycle through residential IPs (use a CGNAT, mobile hotspot, etc.) to evade your per-IP cap</li>
<li>Use Tor (where every exit IP has a DIFFERENT user behind it, so your cap unfairly blocks them)</li>
<li>Use VPNs (similar to above)</li>
</ul>
<p>You'll never block 100% of abuse on the free tier. The goal is not zero abuse — the goal is friction high enough that it's easier to pay $29.</p>
<p>If a specific IP/range becomes a problem, block it via Cloudflare's firewall rules (free, point-and-click in the dashboard).</p>
<h2 id="stripe-test-mode-live-mode">Stripe Test mode → Live mode</h2>
<p>The transition is one toggle in the dashboard. After flipping:</p>
<ul>
<li>All your <code>sk_test_…</code> and <code>pk_test_…</code> keys stop working</li>
<li>You get new <code>sk_live_…</code> and <code>pk_live_…</code> keys</li>
<li>Test products + prices DON'T migrate; you have to recreate them in Live</li>
<li>Test customers + subscriptions don't migrate either</li>
</ul>
<p>Plan a launch checklist:</p>
<pre><code class="lang-"> [ ] Switch dashboard to Live mode
[ ] Recreate Pro and Team products with same names
[ ] Copy new price IDs
[ ] wrangler secret put STRIPE_SECRET_KEY (the live one)
[ ] wrangler secret put STRIPE_PRICE_PRO (the new live ID)
[ ] wrangler secret put STRIPE_PRICE_TEAM
[ ] Update webhook endpoint to point at live secret
[ ] wrangler secret put STRIPE_WEBHOOK_SECRET (live)
[ ] Test with a real $1 charge (use a test plan you can refund)
[ ] Refund yourself
[ ] Announce</code></pre>
<h1 id="10-adding-llm-calls-cost-safely">10. Adding LLM calls cost-safely</h1>
<p>You're charging $29/mo. Your real cost-of-goods is the LLM API call you make on the user's behalf. If a user can run up $50 of LLM cost on their $29 plan, you lose money. The whole game is keeping COGS < revenue per user.</p>
<h2 id="provider-economics-may-2026">Provider economics (May 2026)</h2>
<p>Rough per-call costs for a typical "RAG-shaped" 2k-token in / 1k-token out generation:</p>
<pre><code class="lang-"> Provider Model per-call cost
──────────────────── ──────────────────────────── ─────────────
Anthropic claude-sonnet-4-6 $0.012
Anthropic claude-haiku-4-5 $0.0008
OpenAI gpt-4o $0.018
OpenAI gpt-4o-mini $0.0009
Groq llama-3.3-70b-versatile $0.0005
Cloudflare Workers AI @cf/meta/llama-3.3-70b-… $0.001 (after free tier)
Cloudflare Workers AI @cf/meta/llama-3.3-70b-… FREE (within free tier)</code></pre>
<p>The cheapest path is usually <strong>Workers AI for the bulk + Groq for "needs to be fast" + Anthropic only for the hardest tasks</strong>. If you build a router that picks the cheapest provider that meets the quality bar, you can keep COGS under $0.005/call.</p>
<p>At Pro tier (10k calls/month), that's $50 COGS on $29 revenue — still underwater. Need to either:</p>
<p>1. Lower the per-call cost (cache, downgrade to Haiku/Llama for easy tasks) 2. Lower the monthly cap (5k calls/month, raise the tier price) 3. Offer "bring your own key" tier (you charge $9/mo for the routing, user pays their own LLM bill)</p>
<p>Most successful MCP services in 2026 use option 3: a small flat fee for the infrastructure + the user provides their LLM API key.</p>
<h2 id="cache-aggressively">Cache aggressively</h2>
<p>Same task → same response, 99% of the time. KV-cache the LLM output keyed by the SHA-256 of the input + parameters:</p>
<pre><code class="lang-typescript">const cacheKey = await sha256(`${task.toLowerCase().trim()}::${limit}::${context}`)
const cached = await env.MCP_KV.get(`route:${cacheKey}`, 'json')
if (cached) return json({ ...cached, cache_hit: true })
const result = await callLLM(...)
await env.MCP_KV.put(`route:${cacheKey}`, JSON.stringify(result), { expirationTtl: 86400 })
return json(result)</code></pre>
<p>24-hour TTL is a good default. Most repeat queries hit within minutes.</p>
<h2 id="use-cloudflare-ai-gateway">Use Cloudflare AI Gateway</h2>
<p><a href="https://developers.cloudflare.com/ai-gateway">developers.cloudflare.com/ai-gateway</a> is a free observability + caching layer that sits in front of any LLM provider. Set <code>cache_ttl=86400</code> and identical upstream requests dedupe at the gateway, even when your KV cache misses.</p>
<p>Setup:</p>
<p>1. Dashboard → AI → AI Gateway → "+ Create Gateway" 2. Name it (e.g. <code>my-mcp</code>) 3. Set <code>cache_ttl: 86400</code>, <code>collect_logs: true</code> 4. Replace your LLM provider URLs with the gateway-prefixed version:</p>
<p>``<code> - https://api.groq.com/openai/v1/chat/completions + https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_NAME}/groq/chat/completions </code>``</p>
<p>5. The dashboard now shows every LLM call — cost, latency, status. Free.</p>
<h2 id="pre-validate-to-avoid-wasted-calls">Pre-validate to avoid wasted calls</h2>
<p>Cheap input validation BEFORE the LLM call:</p>
<ul>
<li>Reject empty/too-long inputs (<code>task.length > 800</code>)</li>
<li>Reject obvious-spam patterns</li>
<li>Apply per-key per-day token budgets</li>
</ul>
<p>A user who'd otherwise consume 1M tokens in an afternoon by accident gets a 429 at 200k. They contact you, you raise their cap manually if they have a real use case.</p>
<h2 id="streaming-where-possible">Streaming where possible</h2>
<p>User patience is shorter when nothing visibly happens. Stream LLM tokens back via SSE so they see "the model is writing":</p>
<pre><code class="lang-typescript">const stream = await fetch(`${API_BASE}/chat/completions`, {
body: JSON.stringify({ ..., stream: true }),
})
return new Response(stream.body, { headers: { 'content-type': 'text/event-stream' }})</code></pre>
<p>Doesn't reduce cost but reduces churn. People wait 20s for streamed output where they'd abort at 8s of nothing.</p>
<h2 id="separate-demo-and-real-pricing">Separate "demo" and "real" pricing</h2>
<p>Some users will plug your shim into a CI loop that fires 10k calls/day "because it's just $29/mo". Defeat this by tiering on intent:</p>
<pre><code class="lang-"> Free 5/day per IP demo only
Pro $29 10k/month + soft 100/hour cap individual dev
Team $149 100k/month small team
Enterprise custom talk to me</code></pre>
<p>The hourly cap on Pro stops the CI-loop abuse without inconveniencing real human users.</p>
<h1 id="11-bonus-sse-streaming-for-live-progress-events">11. Bonus: SSE streaming for live progress events</h1>
<p>Server-Sent Events (SSE) is HTTP/1.1's "long-lived response that delivers events as they happen". One-direction, text-based, simple. Perfect for "here's what the LLM is generating right now" UIs.</p>
<h2 id="when-to-use-it">When to use it</h2>
<ul>
<li>Long LLM calls (5-30 seconds) where users need feedback</li>
<li>Multi-stage pipelines (cache lookup → embed → LLM → classify → done) where each stage takes meaningful time</li>
<li>Anything where the user otherwise stares at a spinner</li>
</ul>
<p>When NOT to use it: short responses (< 1s); MCP clients that can't render intermediate output (most stdio MCP clients can't stream tool output, so this only helps if you're also serving a web UI on the same backend).</p>
<h2 id="the-wire-format">The wire format</h2>
<pre><code class="lang-">event: progress
data: {"stage": "cache_miss"}
event: progress
data: {"stage": "llm_streaming", "chars": 1234, "ms": 5234}
event: skill
data: {...full classified skill...}
event: done
data: {...summary fields...}</code></pre>
<p>Each block is <code>event: NAME\ndata: JSON\n\n</code>. Blank line ends one event. JSON-encode every payload to keep newlines from breaking the framing.</p>
<h2 id="server-side-in-a-worker">Server-side in a Worker</h2>
<pre><code class="lang-typescript">const enc = new TextEncoder()
const { readable, writable } = new TransformStream()
const writer = writable.getWriter()
const send = async (event, data) => {
await writer.write(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
}
// Run the pipeline async — the readable goes back to the user immediately
;(async () => {
try {
await send('progress', { stage: 'cache_miss' })
await send('progress', { stage: 'llm_streaming', chars: 100 })
// ...
await send('done', { ok: true })
} catch (e) {
await send('error', { message: e.message })
} finally {
await writer.close()
}
})()
return new Response(readable, { headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'x-accel-buffering': 'no',
'access-control-allow-origin': '*',
}})</code></pre>
<h2 id="client-side-consumer-vanilla-fetch-readablestream">Client-side consumer (vanilla fetch + ReadableStream)</h2>
<p>EventSource doesn't support POST or custom headers, so most real apps use fetch + manual SSE parsing:</p>
<pre><code class="lang-javascript">const res = await fetch('/api/route?stream=1', {
method: 'POST',
headers: { accept: 'text/event-stream', 'content-type': 'application/json' },
body: JSON.stringify({ task })
})
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
let idx
while ((idx = buf.indexOf('\n\n')) !== -1) {
const msg = buf.slice(0, idx)
buf = buf.slice(idx + 2)
let event = 'message'
const dataLines = []
for (const line of msg.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
if (dataLines.length) {
const payload = JSON.parse(dataLines.join('\n'))
handleEvent(event, payload)
}
}
}</code></pre>
<h2 id="forwarding-upstream-llm-streams">Forwarding upstream LLM streams</h2>
<p>Groq, OpenAI, Anthropic all support <code>stream: true</code> and emit OpenAI-format SSE. To forward to your end users:</p>
<pre><code class="lang-javascript">const groq = await fetch('https://api.groq.com/...', {
body: JSON.stringify({ ..., stream: true })
})
for await (const delta of iterOpenAIStream(groq)) {
rawText += delta
// Throttle progress events to avoid overloading the client
if (Date.now() - lastSent > 250) {
await send('progress', { chars: rawText.length })
lastSent = Date.now()
}
}</code></pre>
<p>Where <code>iterOpenAIStream</code> is a small async generator that parses OpenAI's SSE format. ~30 lines, included in the buyer bundle as <code>_stream.ts</code>.</p>
<h2 id="common-pitfalls">Common pitfalls</h2>
<ul>
<li><strong>Backpressure in tests</strong> — Node's TransformStream has a high-water mark of 1. If your test does <code>await send()</code> before the consumer reads, it blocks. Always launch reader concurrently with writer.</li>
<li><strong>Proxy buffering</strong> — Some HTTP intermediaries (nginx) buffer SSE by default. The <code>x-accel-buffering: no</code> header tells nginx not to. CF doesn't buffer SSE.</li>
<li><strong>Cancellation</strong> — If the user closes the tab mid-stream, your Worker keeps running. Listen for <code>request.signal</code> aborted events (or just let it finish — small Worker invocations are cheap).</li>
</ul>
<h1 id="12-bonus-vectorize-backed-result-caching">12. Bonus: Vectorize-backed result caching</h1>
<p>KV cache is exact-match — same query → same response. <strong>Vectorize</strong> is semantic — similar query → cached response (within a similarity threshold). Hugely powerful for natural-language queries where users phrase the same intent ten different ways.</p>
<h2 id="setup-one-time">Setup (one-time)</h2>
<pre><code class="lang-bash">wrangler vectorize create my-cache --dimensions=1024 --metric=cosine</code></pre>
<p>Then bind it in <code>wrangler.toml</code>:</p>
<pre><code class="lang-toml">[[vectorize]]
binding = "VECTORIZE"
index_name = "my-cache"</code></pre>
<h2 id="embedding-via-workers-ai">Embedding via Workers AI</h2>
<p>Workers AI ships several embedding models for free:</p>
<pre><code class="lang-typescript">async function embed(env: Env, text: string): Promise<number[]> {
const out = await env.AI.run('@cf/baai/bge-m3', { text: [text] })
return out.data[0] // 1024-dim float vector
}</code></pre>
<p><code>@cf/baai/bge-m3</code> is multilingual, 1024-dim, and is on the free tier. Use it for both queries and corpus.</p>
<h2 id="lookup-then-llm-pattern">Lookup-then-LLM pattern</h2>
<pre><code class="lang-typescript">async function semanticCacheGet(env: Env, query: string) {
const qVec = await embed(env, query)
const matches = await env.VECTORIZE.query(qVec, { topK: 1, returnMetadata: true })
if (matches.matches.length && matches.matches[0].score >= 0.92) {
// Cache hit — return the previously-cached response
return matches.matches[0].metadata
}
return null
}
async function semanticCachePut(env: Env, query: string, response: any) {
const qVec = await embed(env, query)
await env.VECTORIZE.upsert([{
id: crypto.randomUUID(),
values: qVec,
metadata: response,
}])
}</code></pre>